[Interview]Java 面试宝典系列之 Java 多线程

Posted Spring-_-Bear

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[Interview]Java 面试宝典系列之 Java 多线程相关的知识,希望对你有一定的参考价值。

文章目录

1. 创建线程有哪几种方式?

  1. 继承 Thread 类

    • 自定义类继承 Thread 类,重写 run() 方法处理业务
    • 创建自定义类实例,调用其 start() 方法启动线程
  2. 实现 Runnable 接口

    • 自定义类实现 Runnable 接口,实现 run() 方法处理业务
    • 创建 Thread 类的对象,将自定义线程类的实例传入 Thread 的构造器
    • 调用 Thread 类线程对象的 start() 方法启动线程
  3. 实现 Callable 接口

    • 自定义类实现 Callable 接口,并实现 call() 方法,该方法有返回值,可抛出异常
    • 创建 FutureTask 类的实例,并在构造器中传入自定类的实例
    • 创建 Thread 类的实例,并在构造器中传入 FutureTask 类的实例,调用 Thread 类实例的 start() 方法启动线程
    • 可调用 FutureTask 类实例的 get() 返回获取 call() 方法中的返回值
    /**
     * @author Spring-_-Bear
     * @datetime 2022-07-24 19:46 Sunday
     */
    public class CallableThread implements Callable<Integer> 
        @Override
        public Integer call() 
            int sum = 0;
            for (int i = 1; i <= 10; i++) 
                sum += i;
            
            return sum;
        
    
        public static void main(String[] args) throws Exception
            FutureTask<Integer> futureTask = new FutureTask<>(new CallableThread());
            Thread thread = new Thread(futureTask);
            thread.start();
            Integer res = futureTask.get();
            System.out.println(res);
        
    
    
  4. 使用线程池

扩展阅读之创建线程不同方式间的优缺点对比:

  • 采用实现 Runnable、Callable 接口的方式创建多线程的优缺点:
    • 优点:仅仅是实现了接口,依然可以继承其他类扩展功能;在这种方式下,多个线程可以共享同一个 target 对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将 CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想
    • 缺点:编程实现稍显复杂,如果需要访问当前线程,则必须使用 Thread.currentThread() 方法
  • 采用继承 Thread 类的方式创建多线程的优缺点:
    • 优势:实现简单,如果需要访问当前线程无须使用 Thread.currentThread() 方法,直接使用 this 即可获得当前线程
    • 缺点:继承 Thread 类完成线程功能的类不能再继承其他类扩展功能

2. 说说 Thread 类的常用方法

  1. 构造器

    构造器
    Thread()
    Thread(String name)
    Thread(Runnable target)
    Thread(Runnable target, String name)
  2. 常用静态方法

    方法功能
    currentThread()返回当前正在执行的线程
    interrupted()返回当前执行的中断状态
    sleep(long millis)使当前线程睡眠(单位:毫秒)
    yield()使当前执行的线程自愿暂时放弃对处理器的使用权并允许其他线程执行即线程礼让
  3. 常用实例方法

    方法功能
    getId()返回该线程的 id
    getName()返回该线程的名字
    getPriority()返回该线程的优先级
    setName(String name)设置该线程的名字
    setPriority(int newPriority)改变该线程的优先级
    setDaemon(boolean on)将该线程标记为守护线程或用户线程,如果不标记默认是非守护线程
    interrupt()使该线程中断执行
    isInterrupted()返回该线程的中断状态
    isAlive()返回该线程是否处于活动状态
    isDaemon()返回该线程是否是守护线程
    join()当前线程插队执行,其它线程等待当前线程终止
    join(long millis)等待该线程终止,至多等待多少毫秒数

3. run() 和 start() 有什么区别?

  • run() 方法被称为线程执行体,它的方法体代表了线程需要完成的任务

  • start() 方法用来启动线程。若直接调用线程实例的 run() 方法,则 run() 只是一个普通方法,不支持线程操作

4. 线程是否可以重复启动,会有什么后果?

只能对处于新建状态的线程调用 start() 方法,重复启动线程将引发 IllegalThreadStateException 异常

扩展阅读之线程状态:

当程序使用 new 关键字创建了一个线程之后,该线程就处于新建状态,此时它和其他的 Java 对象一样,仅仅由 JVM 为其分配内存,并初始化其成员变量的值。此时的线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体

当调用了线程对象的 start() 方法之后,该线程处于就绪状态,Java 虚拟机会为其创建方法调用栈和程序计数器,处于这个状态中的线程并没有开始运行,只是表示该线程可以运行了。至于该线程何时开始运行,取决于 JVM 里线程调度器的调度

5. 介绍一下线程的生命周期

在线程的生命周期中,它要经过新建(New)、就绪(Ready)、运行(Running)、阻塞(Blocked)和死亡(Dead)5 种状态。

当线程启动后,它不可能一直 “霸占” 着 CPU 运行,CPU 需要在多条线程之间来回切换,于是线程状态也会多次在运行、就绪之间切换。

  1. 新建(New):当程序使用 new 关键字创建了一个线程之后,该线程就处于新建状态,此时它和其他的 Java 对象一样,仅仅由 Java 虚拟机为其分配内存,并初始化其成员变量的值。此时的线程对象没有表现出任何线程的动态特征,程序也不会执行线程的执行体。

  2. 就绪(Ready):当线程对象调用了 start() 方法之后,该线程处于就绪状态,Java 虚拟机会为其创建方法调用栈和程序计数器,处于这个状态中的线程并没有开始运行,只是表示该线程可以运行了。至于该线程何时开始运行,取决于 JVM 里线程调度器的调度。

  3. 运行(Running):如果处于就绪状态的线程获得了 CPU,开始执行 run() 方法的线程执行体,则该线程处于运行状态。如果计算机只有一个 CPU,那么在任何时刻只有一个线程处于运行状态。当然,在一个多处理器的机器上将会有多个线程并行执行,当线程数大于处理器数时,依然会存在多个线程在同一个 CPU上轮换的现象。

  4. 阻塞(Blocked):当一个线程开始运行后,它不可能一直处于运行状态,线程在运行过程中需要被中断,目的是使其他线程获得执行的机会,线程调度的细节取决于底层平台所采用的策略。

    对于采用抢占式策略的系统而言,系统会给每个可执行的线程一个小时间段来处理任务。当该时间片用完后,系统就会剥夺该线程所占用的资源,让其他线程获得执行的机会。当发生如下情况时,线程将会进入阻塞状态:

    • 线程调用 sleep() 方法主动放弃所占用的处理器资源
    • 线程调用了一个阻塞式 IO 方法,在该方法返回之前,该线程被阻塞
    • 线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有
    • 线程在等待某个通知(notify)
    • 程序调用了线程的 suspend() 方法将该线程挂起(这个方法容易导致死锁,应该尽量避免使用该方法)
  5. 死亡(Dead):线程会以如下三种方式结束,结束后就处于死亡状态

    1. run()call() 方法执行完成,线程正常结束
    2. 线程抛出一个未捕获的 Exception 或 Error
    3. 直接调用该线程的 stop() 方法来结束该线程(该方法容易导致死锁,不推荐使用)

6. 如何实现线程同步?

  1. 同步方法:即有 synchronized 关键字修饰的方法,由于 java 的每个对象都有一个内置锁,当用此关键字修饰方法时, 内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。需要注意, synchronized 关键字也可以修饰静态方法,此时如果调用该静态方法,将会锁住整个类
  2. 同步代码块:即有 synchronized 关键字修饰的语句块,被该关键字修饰的语句块会自动被加上内置锁,从而实现同步。需值得注意的是,同步是一种高开销的操作,因此应该尽量减少同步的内容。通常没有必要同步整个方法,使用 synchronized 代码块同步关键代码即可
  3. ReentrantLock:Java5 新增了 java.util.concurrent 包来支持同步,其中 ReentrantLock 类是可重入、互斥、实现了 Lock 接口的锁,它与使用 synchronized 修饰的方法和代码块具有相同的基本行为和语义,优点是扩展了其能力。需要注意的是,ReentrantLock 还有一个可以创建公平锁的构造方法,由于其大幅度降低了程序运行效率,因此不推荐使用
  4. volatile:volatile 关键字为域变量的访问提供了一种免锁机制,使用 volatile 修饰域相当于告诉虚拟机该域可能会被其他线程更新,因此每次使用该域就需要重新进行计算,而不是使用寄存器中的值。需要注意的是,volatile 不会提供任何原子操作,也不能用来修饰 final 类型的变量
  5. 原子变量:在 java.util.concurrent.atomic 包中提供了创建原子类型变量的工具类,使用该类可以简化线程同步操作。例如 AtomicInteger 表示可以用原子方式更新 int 类型变量的值,可用在应用程序中(如以原子方式增加的计数器),但不能用于替换 Integer

7. 说一说 Java 多线程之间的通信方式

  1. wait()、notify()、notifyAll():如果采用 synchronized 来保证线程安全,则可以利用 wait()、notify()、notifyAll() 来实现线程通信。这三个方法均是 Object 类中声明的方法,原因是每个对象都拥有锁,如果让当前线程等待某个对象的锁,当然应该通过这个对象来操作。这三个方法均是 final native 类型的

    • wait() 方法可以让当前线程释放对象锁并进入阻塞状态

    • notify() 方法用于唤醒一个正在等待相应对象锁的线程,使其进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到 CPU 的执行

    • notifyAll() 用于唤醒所有正在等待相应对象锁的线程,使它们进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到 CPU 的执行

    每个锁对象都有两个队列,一个是就绪队列,一个是阻塞队列。就绪队列存储了已就绪(将要竞争锁)的线程,阻塞队列存储了被阻塞的线程。当一个阻塞线程被唤醒后,才会进入就绪队列,进而等待 CPU 的调度。反之,当一个线程被 wait 后,就会进入阻塞队列,等待被唤醒

  2. await()、signal()、signalAll():如果线程之间采用 Lock 来保证线程安全,则可以利用 await()、signal()、signalAll() 来实现线程通信。这三个方法都是 Condition 接口中的方法,该接口是在 Java1.5 中才出现的,用来替代传统的 wait+notify 实现线程间的协作,使用依赖于 Lock。相比使用 wait+notify,使用 Condition 的 await+signal 这种方式能够更加安全和高效地实现线程间协作。

    Condition 的使用依赖于 Lock 接口,生成一个 Condition 的基本代码是 lock.newCondition()

    需要注意的是,Condition 的 await()/signal()/signalAll() 使用都必须在 lock 保护之内,也就是说,必须在 lock.lock() 和 lock.unlock 之间才可以使用。

    事实上,await()/signal()/signalAll() 与 wait()/notify()/notifyAll() 有着天然的对应关系。

  3. BlockingQueue:Java5 提供了一个 BlockingQueue 接口,虽然 BlockingQueue 也是 Queue 的子接口,但它的主要用途并不是作为容器,而是作为线程通信的工具。

    BlockingQueue 具有一个特征:当生产者线程试图向 BlockingQueue 中放入元素时,如果该队列已满,则该线程被阻塞;当消费者线程试图从 BlockingQueue 中取出元素时,如果该队列已空,则该线程被阻塞。

    程序的两个线程交替向 BlockingQueue 中放入元素、取出元素,可很好地控制线程的通信。线程之间需要通信,最经典的场景就是生产者与消费者模型,而 BlockingQueue 就是针对该模型提供的解决方案。

8. 说一说 Java 同步机制中的 wait 和 notify

只有采用 synchronized 实现线程同步时才能使用 wait()、notify()、notifyAll() 这三个方法来实现线程之间的通信,这三个方法均是 Object 类中的 final native 类型的方法(每个对象都拥有锁,如果让当前线程等待某个对象的锁,当然应该通过这个对象来操作)

  • wait() 方法可以让当前线程释放对象锁并进入阻塞状态

  • notify() 方法用于唤醒一个正在等待相应对象锁的线程,使其进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到 CPU 的执行

  • notifyAll() 方法用于唤醒所有正在等待相应对象锁的线程,使它们进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到 CPU 的执行

9. 说一说 sleep() 和 wait() 的区别

  1. sleep() 是 Thread 类中的静态方法,而 wait() 是 Object 类中的成员方法
  2. sleep() 可以在任何地方使用,而 wait() 只能在同步方法或同步代码块中使用(synchronized)
  3. sleep() 不会释放锁,而 wait() 会释放锁,并且需要通过 notify()/notifyAll() 重新获取锁

10. 说一说 notify()、notifyAll() 的区别

  • notify():用于唤醒一个正在等待相应对象锁的线程,使其进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到 CPU 的执行
  • notifyAll():用于唤醒所有正在等待相应对象锁的线程,使它们进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到 CPU 的执行

11. 如何实现子线程先执行,主线程再执行?

启动子线程后,立即调用该线程的 join() 方法进行线程插队,若插队成功则主线程须等待子线程执行完成后再执行。

12. 阻塞线程的方式有哪些?

  1. 线程调用 sleep() 方法主动放弃所占用的处理器资源;
  2. 线程调用了一个阻塞式 IO 方法,在该方法返回之前,该线程被阻塞;
  3. 线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有;
  4. 线程在等待某个通知(notify);
  5. 程序调用了线程的 suspend() 方法将该线程挂起(这个方法容易导致死锁,尽量避免使用该方法)

13. 说一说 synchronized 与 Lock 的区别

  1. synchronized 是 Java 关键字,在 JVM 层面实现加锁和解锁;Lock 是一个接口,在代码层面实现加锁和解锁。
  2. synchronized 可以用在代码块上、方法上;Lock 只能写在代码里。
  3. synchronized 在代码执行完或出现异常时自动释放锁;Lock 不会自动释放锁,需要在 finally 中显式释放锁。
  4. synchronized 会导致线程拿不到锁一直等待;Lock 可以设置获取锁失败的超时时间。
  5. synchronized 无法得知是否获取锁成功;Lock 可以通过 tryLock 得知加锁是否成功。
  6. synchronized 锁可重入、不可中断、非公平;Lock 锁可重入、可中断、可公平/不公平,并可以细分读写锁以提高效率。

14. 说一说 synchronized 的底层实现原理

  1. synchronized 作用在代码块时,底层是通过 monitorenter、monitorexit 指令来实现的

    • monitorenter:每个对象都是一个监视器锁(monitor),当 monitor 被占用时就会处于锁定状态。线程执行 monitorenter 指令尝试获取 monitor 的所有权时,过程如下:

      • 如果 monitor 的进入数为 0,则该线程进入 monitor,然后将进入数设置为 1,该线程即为 monitor 的所有者;

      • 如果线程已经占有该 monitor,只是重新进入,则进入 monitor 的进入数加 1;

      • 如果其他线程已经占用了 monitor,则该线程进入阻塞状态,直到 monitor 的进入数为 0 方可重新尝试获取 monitor 的所有权

    • monitorexit:执行 monitorexit 的线程必须是 objectref 所对应的 monitor 持有者。指令执行时,monitor 的进入数减1,如果减 1 后进入数为 0,则线程退出 monitor,不再是这个 monitor 的所有者,其他被这个 monitor 阻塞的线程可以尝试获取这个 monitor 的所有权。

  2. synchronized 作用在方法时,并没有通过 monitorenter 和 monitorexit 指令来完成,不过相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。JVM 就是根据该标示符来实现方法的同步的:

    当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放 monitor。在方法执行期间,其他任何线程都无法再获得同一个 monitor 对象

两种同步方式本质上没有区别,只是方法的同步是一种隐式的实现,无需通过字节码来完成。两个指令的执行是 JVM 通过调用操作系统的互斥原语 mutex 来实现,被阻塞的线程会被挂起、等待重新调度,导致线程在 “用户态和内核态” 之间来回切换,对性能有较大影响。

15. synchronized 可以修饰静态方法和静态代码块吗?

synchronized 可以修饰静态方法,但不能修饰静态代码块。当修饰静态方法时,锁住的是对象的 Class 实例,因此静态方法锁相当于该类的一个全局锁。

16. 谈谈 ReentrantLock 的实现原理

ReentrantLock 是基于 AQS(AbstractQueuedSynchronizer) 实现的,AQS 这是个内部实现了同步队列和条件队列的抽象类。

  • 同步队列是一个双向链表,里面储存的是处于等待状态的线程,正在排队等待唤醒去获取锁。在同步队列中存在 2 种模式,分别是独占模式和共享模式,这两种模式的区别就在于 AQS 在唤醒线程节点的时候是不是传递唤醒,这两种模式分别对应独占锁和共享锁。

  • 条件队列是一个单向链表,里面储存的也是处于等待状态的线程,只不过这些线程唤醒的结果是加入到了同步队列的队尾,AQS 所做的就是管理这两个队列里面线程之间的等待状态-唤醒的工作。

AQS 是一个抽象类,所以不能直接实例化,当我们需要实现一个自定义锁的时候可以去继承 AQS 然后重写获取锁的方式和释放锁的方式还有管理 state,而 ReentrantLock 就是通过重写了 AQS 的 tryAcquire 和 tryRelease 方法实现的 lock 和 unlock。

首先 ReentrantLock 实现了 Lock 接口并且有 3 个内部类:Sync 内部类继承自 AQS,另外的两个内部类继承自 Sync,这两个类分别是用来公平锁和非公平锁的。通过 Sync 重写 tryAcquire、tryRelease 可以知道,ReentrantLock 实现的是 AQS 的独占模式,也就是独占锁,这个锁是悲观锁。

17. 如果不使用 synchronized 和 Lock,如何保证线程安全?

  1. volatile:volatile 关键字为域变量的访问提供了一种免锁机制,使用 volatile 修饰域相当于告诉虚拟机该域可能会被其他线程更新,因此每次使用该域就要重新计算,而不是使用寄存器中的值。需要注意的是,volatile 不会提供任何原子操作,它也不能用来修饰 fina l类型的变量。
  2. 原子变量:在 java 的 java.util.concurrent.atomic 包中提供了创建了原子类型变量的工具类,使用该类可以简化线程同步。例如AtomicInteger 可以用原子方式更新 int 的值,可用在应用程序中(如以原子方式增加的计数器),但不能用于替换 Integer。
  3. ThreadLocal:可以通过 ThreadLocal 类来实现线程本地存储的功能。每一个线程的 Thread 对象中都有一个 ThreadLocalMap 对象,这个对象存储了一组以 ThreadLocal.threadLocalHashCode 为键,以本地线程变量为值的 K-V 值对,ThreadLocal 对象就是当前线程的 ThreadLocalMap 的访问入口,每一个 ThreadLocal 对象都包含了一个独一无二的 threadLocalHashCode 值,使用这个值就可以在线程 K-V 值对中找回对应的本地线程变量。
  4. 不可变的 final:只要一个不可变的对象被正确地构建出来,那其外部的可见状态永远都不会改变,永远都不会看到它在多个线程之中处于不一致的状态,“不可变” 带来的安全性是最直接、最纯粹的。Java 语言中,如果多线程共享的数据是一个基本数据类型,那么只要在定义时使用 final 关键字修饰它就可以保证它是不可变的。如果共享数据是一个对象,由于 Java 语言目前暂时还没有提供值类型的支持,那就需要对象自行保证其行为不会对其状态产生任何影响才行。String 类是一个典型的不可变类,可以参考它设计一个不可变类。

18. 说一说 Java 中乐观锁和悲观锁的区别

  • 悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。Java 中悲观锁是通过 synchronized 关键字或 Lock 接口来实现的。

  • 乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。乐观锁适用于多读的应用类型,这样可以提高吞吐量。在 JDK1.5 中新增 java.util.concurrent (J.U.C) 就是建立在CAS 之上的。相对于对于 synchronized 这种阻塞算法,CAS 是非阻塞算法的一种常见实现。所以 J.U.C 在性能上有了很大的提升。

19. 公平锁与非公平锁是怎么实现的?

在 Java 中实现锁的方式有两种,一种是使用关键字 synchronized 对方法或者代码块进行加锁,另一种是 ReentrantLock,前者只能是非公平锁,而后者是默认非公平但可实现公平的一把锁。

ReentrantLock 是基于其内部类 FairSync(公平锁)和 NonFairSync(非公平锁)实现的,并且它的实现依赖于 Java 同步器框架AbstractQueuedSynchronizer(AQS),AQS 使用一个整型的 volatile 变量 state 来维护同步状态,这个 volatile 变量是实现ReentrantLock 的关键。ReentrantLock 的类图如下:

ReentrantLock 的公平锁和非公平锁都委托了 AbstractQueuedSynchronizer#acquire 去请求获取:

public final void acquire(int arg) 
    // tryAcquire 是一个抽象方法,是公平与非公平的实现原理所在
    // acquireQueued 在多次循环中尝试获取到锁或者将当前线程阻塞
    // addWaiter 是将当前线程结点加入等待队列之中。公平锁在锁释放后会严格按照等待队列去取后续值,而非公平锁在对于新晋线程有很大优势 
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        // selfInterrupt 如果线程在阻塞期间发生了中断,调用 Thread.currentThread().interrupt() 中断当前线程
        selfInterrupt();
    

公平锁和非公平锁在锁的获取上都使用到了 volatile 关键字修饰的 state 字段, 这是保证多线程环境下锁的获取成功与否的关键。但是当并发情况下多个线程都读取到 state == 0 时,则必须用到 CAS 技术(一门 CPU 的原子锁技术),可通过 CPU 对共享变量加锁的形式,实现数据变更的原子操作。volatile 和 CAS 的结合是并发抢占的关键。

  1. FairSync:公平锁的实现机理在于每次有线程来抢占锁的时候,都会检查一遍有没有等待队列,如果有, 当前线程会执行如下步骤:

    if (!hasQueuedPredecessors() && compareAndSetState(0, acquires))      
        setExclusiveOwnerThread(current);     
    	return true;  
    
    
  2. NonfairSync:非公平锁在实现的时候多次随机抢占,与公平锁的区别在于新晋获取锁的线程会有多次机会去抢占锁,被加入了等待队列后则跟公平锁没有区别:

    if (c == 0)   
        if (compareAndSetState(0, acquires)) 
            setExclusiveOwnerThread(current);   
            return true;      
         
    
    

20. 了解 Java 中的锁升级吗?

JDK1.6 之前,synchronized 还是一个重量级锁,是一个效率比较低下的锁。但是在 JDK1.6 后,JVM 为了提高锁的获取与释放效率对synchronized 进行了优化,引入了偏向锁和轻量级锁 ,从此以后锁的状态就有了四种:无锁、偏向锁、轻量级锁、重量级锁。并且四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,这四种锁的级别由低到高依次是:无锁、偏向锁,轻量级锁,重量级锁。如下图所示:

  1. 无锁:无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。无锁的特点是修改操作会在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。

  2. 偏向锁:初次执行到 synchronized 代码块的时候,锁对象变成偏向锁(通过 CAS 修改对象头里的锁标志位),字面意思是 “偏向于第一个获得它的线程” 的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程 ID 也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。

    偏向锁是指当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,即提高性能。

    当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。

    偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。关于偏向锁的撤销,需要等待全局安全点,即在某个时间点上没有字节码正在执行时,它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态。

  3. 轻量级锁:轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。

    轻量级锁的获取主要由两种情况:

    • 当关闭偏向锁功能时;

    • 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。

    一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。

    在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS 修改对象头里的锁标志位。先比较当前锁标志位是否为 “释放”,如果是则将其设置为 “锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。

    长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗 CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么 synchronized 就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。

  4. 重量级锁:忙等是有限度的(有个计数器记录自旋次数,默认允许循环 10 次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是 CAS 修改锁标志位,但不修改持有锁的线程 ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。

    重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。简言之,就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资源。

21. 如何实现互斥锁(mutex)?

在 Java 世界里,最基本的互斥同步手段就是 synchronized 关键字,这是一种块结构(Block Structured)的同步语法。synchronized 关键字经过 javac 编译之后,会在同步块的前后分别形成 monitorentermonitorexit 这两个字节码指令。

这两个字节码指令都需要一个 reference 类型的参数来指明要锁定和解锁的对象。如果 Java 源码中的 synchronized 明确指定了对象参数,那就以这个对象的引用作为 reference。如果没有明确指定,那将根据 synchronized 修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的 Class 对象来作为线程要持有的锁。

自 JDK5 起,Java 类库中新提供了 java.util.concurrent 包(J.U.C),其中的 java.util.concurrent.locks.Lock 接口便成了 Java 的另一种全新的互斥同步手段。基于 Lock 接口,用户能够以非块结构(Non-Block Structured)来实现互斥同步,从而摆脱了语言特性的束缚,改为在类库层面去实现同步,这也为日后扩展出不同调度算法、不同特征、不同性能、不同语义的各种锁提供了广阔的空间。

22. 分段锁是怎么实现的?

在并发程序中,串行操作是会降低可伸缩性,并且上下文切换也会减低性能。在锁上发生竞争时将导致这两种问题,使用独占锁保护受限资源的时候时,基本上都是采用串行方式,即每次只能有一个线程能访问它,因而对于可伸缩性来说最大的威胁就是独占锁。

一般情况下有三种方式降低锁的竞争程度:

  1. 减少锁的持有时间;
  2. 降低锁的请求频率;
  3. 使用带有协调机制的独占锁,这种机制允许更高的并发性;

在某些情况下我们可以将锁进一步扩展为一组独立对象上的锁进行分解,这称为分段锁。其实说的简单一点就是:容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是 ConcurrentHashMap 所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

如下图,ConcurrentHashMap 使用 Segment 数据结构,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。所以说,ConcurrentHashMap 在并发情况下,不仅保证了线程安全,而且提高了性能。

23. 说说你对读写锁的了解

与传统锁不同的是读写锁可以共享读,但只能一个写(锁写不锁读),总结起来为:读读不互斥、读写互斥、写写互斥,而一般的独占锁是:读读互斥、读写互斥、写写互斥,而实际应用场景中读往往远远大于写,读写锁就是为了应对这一应用场景而创建出来的一种机制。 注意是读远远大于写,一般情况下独占锁的效率低是原因高并发下对临界区的激烈竞争导致线程上下文切换,因此当并发不是很高的情况下,读写锁由于需要额外维护读锁的状态,可能还不如独占锁的效率高。

在 Java 中 ReadWriteLock 接口的主要实现为 ReentrantReadWriteLock,其提供了以下特性:

  1. 公平性选择:支持公平与非公平(默认)的锁获取方式,吞吐量非公平优先于公平
  2. 可重入:读线程获取读锁之后可以再次获取读锁,写线程获取写锁之后可以再次获取写锁
  3. 可降级:写线程获取写锁之后,其还可以再次获取读锁,然后释放掉写锁,那么此时该线程是读锁状态,也就是降级操作

24. volatile 关键字有什么用?

当一个变量被定义成 volatile 之后,它将具备两项特性:

  1. 保证可见性:当写一个 volatile 变量时,JVM 会把该线程本地内存中的变量强制刷新到主内存中去,这个写操作会导致其他线程中的 volatile 变量缓存无效

  2. 禁止指令重排:使用 volatile 关键字修饰共享变量可以禁止指令重排序,volatile 禁止指令重排序有一些规则:

    • 当程序执行到 volatile 变量的读操作或者写操作时,在其前面的操作肯定已经全部完成,且结果已经对后面的操作可见,在其后面的操作肯定还没有进行
    • 在进行指令优化时,不能将对 volatile 变量访问的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行(即执行到 volatile 变量时,其前面的所有语句都执行完,后面所有语句都未执行,且前面语句的结果对 volatile 变量及其后面语句可见)

注意,虽然 volatile 能够保证可见性,但它不能保证原子性。volatile 变量在各个线程的工作内存中不存在数据不一致性的问题,但 Java 里的运算符并非原子操作,这会导致 volatile 变量的运算在并发下一样是不安全的。

25. 谈谈 volatile 的实现原理

volatile 可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。

在 JVM 底层 volatile 是采用 “内存屏障” 来实现的。观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入 volatile 关键字后,会多出一个 lock 前缀指令,lock 前缀指令实际上相当于一个内存屏障,内存屏障会提供以下 3 个功能:

  1. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面,即在执行到内存屏障这句指令时,在它前面的操作已经全部完成
  2. 它会强制将对缓存的修改操作立即写入主存
  3. 如果是写操作,它会导致其他 CPU 中对应的缓存行无效

26. 说说你对 JUC 的了解

JUC 是 java.util.concurrent 的缩写,该包参考自 edu.oswego.cs.dl.util.concurrent,是 JSR166 标准规范的一个实现。JSR166 是一个关于 Java 并发编程的规范提案,在 JDK 中该规范由 java.util.concurrent 包实现。即 JUC 是 Java 提供的并发包,其中包含了一些并发编程用到的基础组件

JUC 这个包下的类基本上包含了我们在并发编程时用到的一些工具,大致可以分为以下几类:

  • 原子更新:Java 从 JDK1.5 开始提供了 java.util.concurrent.atomic 包,方便程序员在多线程环境下进行无锁的原子操作。在 Atomic 包里一共有 12 个类,四种原子更新方式,分别是原子更新基本类型,原子更新数组,原子更新引用和原子更新字段
  • 锁和条件变量:java.util.concurrent.locks 包下包含了同步器的框架 AbstractQueuedSynchronizer,基于 AQS 构建的 Lock 以及与 Lock 配合可以实现等待/通知模式的 Condition。JUC 下的大多数工具类用到了 Lock 和 Condition 来实现并发
  • 线程池:涉及到的类比如:Executor、Executors、ThreadPoolExector、 AbstractExecutorService、Future、Callable、ScheduledThreadPoolExecutor 等
  • 阻塞队列:涉及到的类比如:ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、LinkedBlockingDeque 等
  • 并发容器:涉及到的类比如:ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue、CopyOnWriteArraySet等
  • 同步器:剩下的是一些在并发编程中时常会用到的工具类,主要用来协助线程同步。比如:CountDownLatch、CyclicBarrier、Exchanger、Semaphore、FutureTask 等

27. 说说你对 AQS 的理解

抽象队列同步器 AbstractQueuedSynchronizer (以下简称 AQS),是用来构建锁或者其他同步组件的骨架类,减少了各功能组件实现的代码量,也解决了在实现同步器时涉及的大量细节问题,例如等待线程采用 FIFO 队列操作的顺序。在不同的同步器中还可以定义一些灵活的标准来判断某个线程是应该通过还是等待。

AQS 采用模板方法模式,在内部维护了 n 多的模板的方法的基础上,子类只需要实现特定的几个方法就可以实现自己的需求

基于 AQS 实现的组件,诸如:

  • ReentrantLock 可重入锁(支持公平和非公平的方式获取锁)
  • Semaphore 计数信号量
  • ReentrantReadWriteLock 读写锁

28. LongAdder 解决了什么问题,它是如何实现的?

高并发下计数,一般最先想到的应该是 AtomicLong/AtomicInt,AtmoicXXX 使用硬件级别的指令 CAS 来更新计数器的值,这样可以避免加锁,效率也很高。但 AtomicXXX 中的 CAS 操作在出现线程竞争时,失败的线程会白白地循环一次,在并发很大的情况下,因为每次CAS 都只有一个线程能成功,竞争失败的线程会非常多,失败次数越多,循环次数就越多,很多线程的 CAS 操作越来越接近自旋锁(spin lock)。计数操作本来是一个很简单的操作,实际需要耗费的 CPU 时间应该是越少越好,AtomicXXX 在高并发计数时,大量的 CPU 时间都浪费会在自旋上,降低了实际的计数效率。

LongAdder 是 JDK8 新增的用于并发环境的计数器,目的是为了在高并发情况下代替 AtomicLong/AtomicInt,是一个用于高并发情况下的高效通用计数器。为什么说在高并发时 LongAdder 比 AtomicLong 更高效呢?LongAdder 是根据锁分段来实现的,它里面维护一组按需分配的计数单元,并发计数时,不同的线程可以在不同的计数单元上进行计数,这样减少了线程竞争,提高了并发效率。本质上是用空间换时间的思想,不过在实际高并发情况中消耗的空间可以忽略不计。

现在,在处理高并发计数时应优先使用 LongAdder,而不是继续使用 AtomicLong。当然,线程竞争很低的情况下进行计数,使用Atomic 还是更简单更直接,并且效率稍微高一些。其他情况,比如序号生成这种情况下需要准确的数值,全局唯一的 AtomicLong 才是正确的选择,此时不应该使用 LongAdder。

29. 介绍下 ThreadLocal 和它的应用场景

ThreadLocal 顾名思义是线程私有的局部变量存储容器,可以理解成每个线程都有自己专属的存储容器,它用来存储线程私有变量,其实它只是一个外壳,内部真正存取是一个 Map。每个线程可以通过 set() 和 get() 存取变量,多线程间无法访问各自的局部变量,相当于在每个线程间建立了一个隔板。只要线程处于活动状态,它所对应的 ThreadLocal 实例就是可访问的,线程被终止后,它的所有实例将被垃圾收集。总之记住一句话:ThreadLocal 存储的变量属于当前线程。

ThreadLocal 经典的使用场景是为每个线程分配一个 JDBC 连接 Connection,这样就可以保证每个线程的都在各自的 Connection 上进行数据库的操作,不会出现 A 线程关了 B 线程正在使用的 Connection。 另外 ThreadLocal 还经常用于管理 Session 会话,将 Session 保存在 ThreadLocal 中,使线程处理多次处理会话时始终是同一个 Session。

30. 请介绍 ThreadLocal 的实现原理,它是怎么处理 hash 冲突的?

Thread 类中有个变量 threadLocals,它的类型为 ThreadLocal 中的一个静态内部类 ThreadLocalMap,该类没有实现 map 接口但实现了类似 map 的功能。每个线程都有自己的一个 map,map 是一个数组,每个元素是一个 Entry,entry 的 key 是 ThreadLocal 的引用,也就是当前变量的副本,value 就是 set 的值。代码如下所示:

public class Thread implements Runnable      
    /* 
     * ThreadLocal values pertaining to this thread. This map is maintained by the ThreadLocal class. 
     */     
    ThreadLocal.ThreadLocalMap threadLocals = null;     

ThreadLocalMap 是 ThreadLocal 的内部类,每个数据用 Entry 保存,其中的 Entry 继承自 WeakReference,用一个键值对存储,键为 ThreadLocal 的引用。为什么是 WeakReference 呢?如果是强引用,即使把 ThreadLocal 设置为null,GC 也不会回收。代码如下所示:

static class Entry extends WeakReference<ThreadLocal<?>> 
    /** The value associated with this ThreadLocal. */
    Object value;

    Entry(ThreadLocal<?> k, Object v) 
        super(k);
        value = v;