spice and wolfspice and wolf Be the One you wanna Be

java并发编程基础面试题

说说偏向锁、轻量级锁和重量级锁

synchronized基本加锁方式

都是java同步关键字synchronized的锁机制,synchronized是在jvm虚拟机层面实现的并发控制,是基于锁对象进行的加锁解锁,编译器会在synchronized同步代码块前后插入monitorenter和monitorexit指令,当虚拟机执行到monitorenter时会尝试获取锁,执行到monitorexit时会尝试释放锁,这就是synchronized的基本锁机制。
synchronized有三种锁机制,其中轻量级锁和偏向锁是java se1.6中为了应对不同的并发场景而加入的。

三种锁的不同点

  • 偏向锁。
    • 场景:适用于只有一个线程获取锁,且无竞争的场景。
    • 特点:
      • 持锁线程不会主动释放锁。
      • 无竞争情况下,持锁线程再次获取锁不需要进行CAS操作。
    • 获取锁流程:
      • 在线程栈帧中增加锁记录。
      • 判断是否是偏向锁。
        • 如果无锁,CAS尝试获取锁。
        • 不是偏向锁,走锁升级操作。
        • 是偏向锁,则判断markword是否偏向当前线程
          • 是,获取锁。
          • 不是(说明存在锁竞争),走撤销偏向锁流程。
    • 撤销锁流程:
      • 在全局安全点,暂停持锁线程。
      • 判断持锁线程是否存活。
        • 否,将对象头置为无锁状态,并根据竞争的激烈程度决定是否进行锁升级(轻或重),最后唤醒其他阻塞线程。
        • 是,则进行锁升级(轻或重),唤醒持锁线程在内的阻塞线程。
  • 轻量级锁。
    • 使用场景。存在锁竞争,但是锁竞争较少且竞争时间不长的情况。
    • 特点。在基本上没有竞争冲突时性能较好,避免了阻塞前后上下文切换的时间。
    • 获取锁流程。
      • 在线程栈帧中添加锁记录。
      • 将锁对象头中的Markword(如果锁被其他线程持有,通过指针找到栈帧中的锁记录后,从锁记录中获取)存储到锁记录中。
      • 使用CAS操作尝试将锁对象头的Markword替换为锁记录的地址。
        • 如果成功,则当前线程获取锁。
        • 如果失败,表示其他线程持有锁,便通过自旋尝试获取锁。自旋一定次数后仍没有获取锁,则修改锁标志位,膨胀为重量级锁,当前线程阻塞。
    • 释放锁流程。
      • 使用CAS尝试将锁记录中的markword替换回对象头中。
        • 成功。释放锁,唤醒其他线程。
        • 失败。说明锁已经膨胀为重量级锁,同样释放锁,唤醒其他线程。
  • 重量级锁。
    • 使用场景。多线程竞争频繁的场景。
    • 特点。
      • 竞争失败即阻塞。
      • 存在上下文切换,会损耗并发性能。
    • 获取锁流程。
      • 判断锁对象的monitor对象的owner是否为null。
        • 如果是,则将monitor对象的owner修改为当前线程地址。
        • 如果不是,则将当前线程放入EntryList队列中,并阻塞当前线程。
    • 释放锁流程。
      • 将owner置为null,并唤醒阻塞队列和等待队列中的线程。

说说Lock和synchronized

相同点

Lock是JUC中的一个接口,synchronized是java的一个并发同步关键字。功能上来说都是解决线程安全问题的工具。

区别

  • 实现方式。
    • synchronized是JVM层面实现的锁同步机制。
    • Lock是JDK API层面实现的锁同步机制,可以自定义实现类,典型的ReentrantLock就是同步器AQS实现的同步锁。
  • 加锁粒度。
    • synchronized加锁方式有三种,普通方法、静态方法和同步代码块,加锁粒度分别是方法代码和包囊的同步代码块中的代码。
    • Lock加锁粒度则取决于lock()和unlock()方法。
  • 应用灵活性。
    • Lock可使用非阻塞加锁方式,而Synchronized不行。
    • Lock可自行决定加锁解锁时机,而Synchronized则是在进入离开同步代码块时自动进行加锁解锁。
    • 可扩展性上Lock远优于synchronized,Lock可以实现公平锁、非公平锁、自旋锁、读写锁来应对各种使用场景,而synchronized实现则是耦合到了JVM中,没有扩展性。
  • 性能上相差无几。
    • synchronized引入了偏向锁、轻量级锁和锁升级机制来实现性能优化。
    • Lock则可以通过自旋锁来实现性能优化。

锁(ReentrantLock)的实现原理(AQS)

JUC中很多类都是基于AQS实现的同步机制,Doug Lea希望它能作为构建锁或其他同步组件的基础框架来实现大部分同步需求。AQS是基于模版方法去设计实现的。

AQS解决了四个核心问题:

  1. 互斥变量的的设计。通过单个int类型变量实现,并且通过volatile保证其可见性,基于CAS操作保证数值操作的原子性。一般实现思路是0表示没有线程持有锁,大于0表示存在线程持有锁,值是重入次数。
  2. 如何实现线程的等待和唤醒。
    • 同步队列。AQS依赖同步队列(一个双向队列)来完成同步状态的管理。
      • 当线程获取同步状态失败时,会基于当前线程及等待状态创建一个节点并插入到同步队列的队尾,并阻塞当前线程。
      • 当持有同步状态的线程释放同步状态时,会唤醒头节点的后继节点,并尝试让后继节点获取同步状态,成功则将头节点移出队列。
    • 通过JUC的同步类LockSupport实现底层的等待和唤醒。
  3. 竞争的公平性。
    • 非公平。当线程尝试获取同步状态时会让当前线程尝试获取一次同步状态,如果失败了则插入到同步队列中,如果成功了则直接执行线程逻辑。
    • 公平锁。当线程尝试获取同步状态时让其直接插入到同步队列,不会进行一次同步状态获取的尝试。
  4. 可重入性。可重入性需要根据tryAcquire等抽象方法的具体实现来决定,一般是基于state和exclusiveOwnerThread两个成员变量来构筑可重入性逻辑。
    • 可重入一般逻辑。当state大于0时,如果当前线程等于exclusiveOwnerThread,基于重入次数累计state。
    • 不可重入(基于AQS给的模版方法不能实现)。

AQS为什么采用双向队列?

双向队列对比普通队列的好处:

  • 双向队列对比普通队列多了一个指向前续节点的指针。
    • 能直接获取直接前驱节点。
    • 能进行前序遍历。
  • 双向队列能在任意节点的插入或删除直接前驱节点和直接后继节点,且时间复杂度为O(1)。

AQS在哪些地方使用了以上特性:

  • acquireQueued方法中,在自旋体中需要获取当前节点的前驱节点,判断前驱节点是否是头节点,如果是头节点就尝试获取同步状态,如果不是头节点,则继续阻塞。
  • shouldParkAfterFailedAcquire方法中,会判断当前节点的前驱节点是否是cancelled状态,如果是则需要删除前驱节点

读写锁的实现原理

读写锁是基于AQS同步器实现的读写同步组件,它实现了共享读、读写互斥和写写互斥的读写机制。解决了三个核心问题:

  1. 读写状态的设计。基于AQS同步状态实现了读写状态的表示:
    • 用state变量的高16位表示读状态。通过向右16位位运算得到读状态值;共享读时计算所有线程获取读状态次数的总和。
    • 用state变量的低16位表示写状态。通过和0x0000FFFF作&运算得到写状态值;当写状态大于0时,通过写状态值获取写锁的重入次数。
  2. 读锁的获取与释放。
    • 获取。如果其他线程已经获取写锁,则获取失败,进入同步队列;如果当前线程已经获得写锁或无线程持有写锁,则当前线程成功获取读状态,读状态增加。
    • 释放。减少读状态。
  3. 写锁的获取与释放(与一般排他锁类似)。
    • 获取。如果没有线程持有写锁且无线程持有读锁,则尝试获取写锁,成功则增加写状态,失败则进入同步队列。
    • 释放。减少写状态。

锁降级是如何实现的

锁降级是获取写锁的线程直接降级到读锁的过程。

需要通过获取写锁->获取读锁->释放写锁的流程来实现。

在写锁释放后再获取读锁是不行的,原因在于为了保证数据操作的原子性和可见性,不同线程间的读写操作是互斥的,即在进行读操作时其他线程不能进行写操作,而在进行写操作时其他线程不能进行读操作。所以为了避免在写锁释放后其他线程抢占到写锁,导致获取读锁后读出的数据与写操作时不一致,就需要在持有写锁的同时来获取读锁,保证数据读取的可见性。

Condition的实现原理

condition是基于Lock接口的一种复数锁实现机制。主要功能包括主动等待和主动唤醒。所以实现condition需要解决的核心问题有二:

  1. 如何实现复数锁机制。在Lock接口中给出了一个newCondition()方法用于返回一个Condition实现类的实例,Condition实例会维护一个等待队列,这就能通过创建多个Condition实例来达到复数锁的目的。
  2. 如何实现等待和唤醒操作。
    • 等待队列。AQS依赖Condition实现类ConditionObject来管理线程的等待和唤醒。
      • 当获取锁的线程时调用await()方法时,会将当前线程插入等待队列,如果同步队列中存在当前线程的节点,将会节点移出同步队列,最后阻塞当前线程。
      • 当当前线程从等待队列唤醒时,会从等待队列中删除,并加入同步队列队尾,等待获取同步状态。

Object监视器和Condition的区别

  • 相同点。都能实现线程间的等待唤醒操作,是解决线程安全问题的工具。
  • 不同点。
    • 实现方式。
      • Object监视器是在JVM层面实现等待唤醒机制的,只是在Object类上提供了方法调用接口。
      • Condition则是基于Java API实现的,底层的阻塞和唤醒操作则是调用了LockSupport的相关方法实现。
    • 使用。
      • Object监视器需要和Synchronized关键字配合使用,只能基于Synchronized加锁的对象去进行等待和唤醒操作。
      • Condition需要和Lock配合使用,可以在一个Lock对象上创建对个Condition锁对象,每个锁对象维护各自的等代队列,适用于更复杂的并发场景。

ConcurrentHashMap实现原理

ConcurrentHashMap是JUC中的一个类,能实现线程安全的数据读写操作。

  • 数据结构。ConcurrentHashMap数组与链表和红黑树的方式来存储数据。初始化时数组的默认长度为16。因为是基于key的hash值进行的寻址,所以必然存在hash冲突问题,使用了两种方式来解决hash冲突。
    • 再哈希。采用XOR(异或)的方式进行再哈希,让元素均匀分布在数组中,降低hash冲突的发生概率。
    • 链地址法。当发生hash冲突时,将冲突节点维护成链表(或红黑树)来解决hash冲突。当链表的长度大于等于8时,会转换成红黑树,而当链表长度小于8时,将会退化成链表进行存储。
  • 加锁机制。
    • jdk1.8及之后,使用CAS加synchronized方式实现并发控制。
      • 现将数组中每个元素比做一个桶。当桶为空时,采用CAS方式添加增加节点。
      • 当桶元素总数大于1时(形成了链表或红黑树时),采用synchronized给链表头(根节点)元素加锁的方式来添加或删除节点。
    • jdk1.8之前,使用分段锁机制。
      • Segment继承ReentrantLock。对节点的数据进行修改时,会先获取对象segment元素的锁。

Java线程池原理

线程池是一种用于线程管理和调度的常用并发框架,通过对线程自动化的创建和管理,避免频繁创建和销毁线程带来的开销,达到提高性能和响应速度的目的。

线程池关键参数有:核心线程数,最大线程数,空闲线程存活时间,时间单位,阻塞队列,线程工厂,拒绝策略。

线程池的遵循一套任务处理流程,通过这套流程来实现线程的管理和调度:

  1. 任务数小于核心线程数时,无论线程是否空闲,都会新创建一个线程执行任务。
  2. 任务数大于核心线程数时,新来的线程任务会到阻塞队列中等待,核心线程会依次从阻塞队列中取出任务执行。
  3. 当阻塞队列满,线程数小于最大线程数时,会创建非核心线程执行任务。
  4. 当阻塞队列满,且线程数达到最大线程数时,这时会采用拒绝策略处理任务。

死锁如何产生的?该如何避免?

死锁是多个线程在执行过程中争夺同一个共享资源造成的相互等待,在没有外在人工干预的情况下会一直阻塞的现象。

造成死锁的条件有四个,当这四个条件同时满足时就会产生死锁:

  1. 互斥条件。共享资源A和B只能被一个线程占用。
  2. 请求和保持条件。线程T1已经取得共享资源A,在等待共享资源B的时候,不释放共享资源A。
  3. 不可抢占条件。其他线程不能强行抢占线程T1占有的资源。
  4. 循环等待条件。线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源就是循环等待。

死锁产生后需要认为干预来解决死锁问题,比如杀掉死锁线程或重启服务。

编码阶段只要破环死锁产生条件的其中一个就能避免死锁产生(互斥条件或因业务需求破坏不了):

  1. 同时申请所有资源。
  2. 在线程获取资源A时,如果发现资源B已经被其他的线程抢占了,则释放锁进行等待。
  3. 按序申请资源。即将资源按特定顺序需排列,只有申请到小序号资源后才能申请更大序号的资源,这样就能破环成环条件。

编码过程中的实际解决方案:

  1. 注意枷锁顺序,保证每个线程按同样的顺序进行加锁。
  2. 注意加锁时限,可以给锁加上一个超时时间,持有锁一段时间后会自动释放锁资源。
  3. 死锁检查。编码过程中提前预防代码死锁,并在发现死锁的第一时间内进行解决。

如何理解线程安全?

线程安全就是在不做任何人工干预的情况下,多线程环境下程序能按预期逻辑执行,这样的程序我们就称为线程安全的。

线程安全性表现在三个方面:

  1. 可见性。可见性是指内存可见性,即线程对共享变量的修改对其他线程是否立即可见。
    • 可见性在java内存模型中的体现。java内存模型将内存分为共享内存和工作内存,共享变量放在共享内存中,每个线程在自己的工作内存存储共享变量的副本。线程通信是多个线程通过对同一个共享变量的读取和修改操作实现的。而可见性就是在一个线程修改了工作内存的本地变量之后,并没有及时刷新到共享内存的共享变量上,导致其他线程拿到的仍然是共享内存上的旧值导致的。
    • 导致内存可见性的原因。
      • CPU高速缓存。
      • CPU指令重排。
      • 编译器指令重排。
    • 解决内存可见性问题的方案。volatile,锁等机制。
  2. 原子性。表示在执行一个或一系列指令时,需要是不可中断的,要么同时执行,要么同时不执行。具备原子性的这些操作被称为原子操作。
    • 导致原子性问题的原因。CPU的时间片抢占。
    • 解决原子性问题的方案。原子性操作和锁机制。
  3. 有序性。表示程序编写的指令顺序和程序运行的指令顺序可能出现不一致问题。
    • 导致有序性的问题的原因。
      • 编译器指令重排。
      • CPU指令重排。
      • 内存系统指令重排。
    • 解决方法。volatile、锁、final域手段来防止关键指令重排。因为java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的指令重排序。

可见性、原子性和有序性产生的根本原因在于软硬件工程师为了有效提高CPU性能对其进行的一系列优化导致的。比如为了提高CPU的存取效率设计了三级缓存,StoreBuffer、缓存行等机制,为了提高指令性能而设计的编译器指令优化等。

什么是阻塞队列?

阻塞队列是一种线程安全的数据结构,用于在多线程环境下进行数据交换,支持阻塞得插入和移出元素。

方法/处理方式抛出异常返回特殊值一直阻塞超时退出
插入方法add(e)offer(e)put(e)offer(e, time, unit)
移出方法remove()poll()take()poll(time, unit)
检查方法element()peek()
插入和移除操作的四种处理方式

阻塞队列接口的实现类有:

类名特点
ArrayBlockingQueue由数组结构构成的有界阻塞队列
LinkedBlockingQueue由链表结构构成的有界(默认值为Integer.MAX_VALUE)阻塞队列
PriorityBlockingQueue支持优先级排序的无界阻塞队列
DelayQueue使用优先级队列实现的延迟无界阻塞队列
SynchronousQueue不存储元素的阻塞队列,也即单个元素的队列
LinkedTransferQueue由链表构成的无界阻塞队列
LinkedBlockingDeque由链表构成的双向阻塞队列
7个阻塞队列及特点

主要用于生产者消费者模式中,可以实现以下特性:

  • 在队列为空时,从队列中获取元素会阻塞;当生产者向队列中添加元素时,阻塞的消费者队列会被唤醒。
  • 在队列满时,向队列中添加元素会阻塞;当消费者从队列中消耗元素时,阻塞的生产者队列会被唤醒。

JAVA中创建线程的方法

继承Thread类

class MyThread extends Thread {
    public void run() {
        // TODO
    }
    
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start();
    }
}

实现Runnable接口

public class MyRunnable implements Runnable {
    @Override
    public void run() {
        // TODO
    }

    public static void main(String[] args) {
        Thread thread = new Thread(new MyRunnable());
        thread.start();
    }
}

使用匿名内部类

public class NoNameThread {
    public static void main(String[] args) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                // TODO
            }
        });
        thread.start();
    }
}

使用Lambda表达式

public class LambdaThread {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            // TODO
        });
        thread.start();
    }
}

使用线程池

public class ExecutorThread {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(5);
        executorService.submit(() -> {
            // TODO
        });
        
    }
}

什么是守护线程

java线程分为用户线程和守护线程,用户线程就是普通的线程,守护线程是jvm的后台线程,在所有用户线程结束之后守护线程会自动关闭。可以通过thread.setDeamon(true)的方式将一个线程设置为守护线程。

CountDownLatch用法及其底层原理

CountDownLatch表示计数器,创建CountDownLatch对象时可以设置一个数字,它有两个方法:

  • await()。当一个线程调用await()时将会阻塞。
  • countDown()。当一个线程调用countDown()方法时,CountDownLatch设置的数字会建减1,当数字减为0时,await()的线程会被唤醒。

底层原理:调用await()方法的线程会利用AQS排队,一旦数字被减为0,则会将AQS中排队的线程依次唤醒。

Semaphore用法及底层原理

Semaphore表示信号量,可以设置许可的个数,表示同事允许最多多少个线程使用该信号量,通过acquire()来获取许可,如果没有许可可用则线程阻塞,并通过AQS排队,可以通过release()方法来释放许可,当某个线程释放了某个许可后,会从AQS中正在排队的第一个线程开始依次唤醒,知道没有空闲许可。

ReentrantLock中的公平锁和非公平锁的底层实现

首先ReentrantLock是基于AQS做代码的同步控制的,在创建ReentrantLock锁对象时,通过可以通过构造函数决定是创建公平锁还是非公平锁,如果是无参构造函数则默认是非公平锁。

其中sync变量则表示的是同步器,即AQS的实现类

但是这里的Sync其实并不完整,只重写了tryRelease和isHeldExclusively方法,要作为同步器使用得至少现实一个tryAcquire方法。为啥呢?我想Doug Lea在有两点考量:

  • 通过抽象类做了一层共用代码的提取。这里nonfairTryAcquire、tryRelease、isHeldExclusively包括底下一众方法的逻辑不管在公平锁中还是非公平锁中都是一样的,其中nonfaireTryAcquire本身还为后面的实现类提供了tryAcquire方法的底层实现,所以这里其实是做了一次公共方法的抽取,将实现类中可能出现的公共逻辑提取到了抽象类中。
  • lock方法的抽象(策略模式思维)。ReenTrantLock并发组件涵盖了公平锁和非公平锁,其中公平锁和非公平锁的锁方法(lock)是基于两类同步器来实现,分别是NonfaireSync和FairSync,公平锁和非公平锁的主要逻辑区别就在lock方法和tryAcquire方法。
非公平锁的实现

首先我们发现,ReenTrantLock在Sync实现类这一层才重写了tryAcquire方法,所以我们要知道Sync实现类这一层才是真正可用于代码并发控制的同步器。在看到lock方法的具体实现,这里直接进行了一次state值的CAS操作,我们先记住这次获取锁的尝试并继续往下看,当这次尝试失败时会执行acquire(1)

acquire方法是AQS模版方法内写死的,主要是看tryAcquire的实现,在NonfairSync中tryAcquire方法的实现就是Sync中的NonfairTryAcquire方法的调用

除去获取当前线程和state的值外,判断state是否等于0,如果等于0就尝试修改state值,否则判断持锁线程是否是当前线程,如果是走重入锁逻辑,否则返回false。可以看到这里在判断state等于0后也做了一次state的CAS操作,加上lock方法中的一次,实际上尝试了两次锁的获取,不同的是第一次没进行state的判断直接尝试获取锁,而第二次则需要进行一次状态判断和jvm层面方法的入栈操作,能把lock方法中尝试获取锁的操作删掉吗?逻辑上貌似没啥问题,个人感觉Doug Lea这里的设计初衷应该是为了在高并发环境下尽量榨取服务器性能,所以才如此设计,第一次获取锁的尝试相比第二次尝试少了很多state变量的获取和判断等操作,也节省了方法的入栈时间。

公平锁的实现

这里lock方法是直接调用acquire方法的,acquire方法在模版方法中写死了,会先调用tryAcquire方法,

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

Press ESC to close