深入理解 Java 线程与锁

我们在之前的《深入理解Java内存模型》中了解到想要线程安全,就需要保证有序性,可见性,原子性,三个都满足的条件下程序才不会出现并发问题,在《深入理解volatile关键字》我们知道了volatile只能保证有序性和可见性,保证不了原子性,而互斥锁是可以保证这三大特性的,今天我们就来了解下在Java中的锁是如何保证线程安全的以及锁的底层原理。在介绍锁之前先介绍下Java的线程和线程调度。

一. Java中的线程

线程是如何实现的?

线程的实现其实有三种方式:内核线程 1:1 实现用户线程的 1:N 实现用户线程加轻量级进程 N:M 混合实现

而现在主流的Java虚拟机都是通过内核的线程1:1实现的,那内核线程1:1实现的这种方式具体是怎么实现的?

  • 内核线程就是直接由操作系统内核支持的线程,线程切换交由内核去做,内核通过操纵调度器对线程进行调度,并且将负责的线程任务映射到各个处理器上,且程序不会直接调用内核线程,而是使用的内核线程的高级接口 - 轻量级进程,每个轻量级进程都是由一个内核线程支持。这个轻量级进程就是我们平时常说的线程,但是,由于轻量级进程是基于内核线程实现的,所以线程的各种创建调度操作都需要进行用户态和内核态的转换,并且轻量级进程需要消耗一定的内核资源。

刚才说了“线程的各种创建调度操作都需要进行用户态和内核态的转换”,想必你也经常听说“用户态和内核态的转换开销很大”,你知道为什么用户态和内核态的转换开销很大吗?

1. 切换时需要保存用户态的上下文,寄存器信息等;

2. 用户态 -> 内核态:需要将用户态的参数复制到内核态;内核态 -> 用户态:将内核态的结果复制到用户态;

3. 调用内核操作有额外的检查。

总而言之,用户态和内核态的切换开销很大。

二. Java线程中的调度和生命周期

Java线程有六种线程状态,可以通过特定的方法进行这六种状态的转换。

  • 新建(New):创建后还未启动线程处于这种状态,比如 new Thread() 还未调用 Thread::start() 方法。

  • 运行(Runnable):运行态这个状态包括两种状态:准备运行和运行中,运行中就是有可能已经获取到cpu执行的时间片已经在执行中,而准备运行就是正在等待操作系统分配时间片去执行。调用 Thread::start() 方法就会进入运行态(

    在Java中认为只要启动的线程不管是否抢到cpu的执行权都算运行态,而在操作系统中却分为两种状态

    )。

  • 无限等待(Waiting):处于这种状态的线程不会被分配处理器的执行时间,需要无限期等待被其他线程显式唤醒。

让线程进入 Waiting 状态的方法:

1. 没有设置 timeout 超时时间的 Object::wait() 方法;

2. 没有设置 timeout 超时时间的 Thread::join() 方法;

3. LockSupport::park() 方法。

  • 有限等待(Time Waiting):处于这种状态的线程同样不会被分配处理器的执行时间,但是和和无限等待(Waiting)的区别是不需要被显示唤醒,而是等待一段时间后会被系统自动唤醒。

让线程进入 Time Waiting 状态的方法:

1. Thread::sleep() 方法;

2. 设置了 Timeout 超时时间的 Object::wait() 方法;

3. 设置了 Timeout 超时时间的 Thread::join() 方法;

4. LockSupport::parkNanos() 方法;

5. LockSupport::parkUntil() 方法。

  • 阻塞(Blocked):当线程等待着获取到一个排他锁就会进入该状态,比如synchronized同步方法,所有分配到cpu执行时间的线程一起抢到锁,抢到的进入运行态,没抢到的锁的进入阻塞态。

  • 结束(Terminated):线程终止的状态,代表线程生命周期的结束。

三. Java同步锁:synchronized

synchronized 是 Java 的同步关键字,有同步方法同步代码块两种用法,用来保证方法或代码块在同一时间只有一个线程能进入,并且需要依赖某个对象资源或类资源进行同步。

在分析原理之前,需要了解下“Mark Word”这个概念

在 JVM 中 Java 对象在内存中的分布布局有三个部分:对象头,实例数据,对齐数据

而这个对象头就是实现 synchronized 的基础,我们重点来看下对象头:

  • 对象头又分为两个部分:Mark WordClass Metadata Address(类型指针)。

  • Mark Word 用来存储对象的 hashCode、锁信息、分代年龄或 GC 标志等信息。(由于对象头是与对象实例数据无关的信息,考虑到 JVM 的空间效率,被设计成了非固定的数据结构,如下图)

  • Class Metadata Address 用来存储指向对象的类元数据指针,JVM 可以通过该指针判断属于哪个类。

我们来通过字节码看下 synchronized 是如何保证线程安全的三大特性(原子性,有序性,可见性)的。

public class SyncDemo {

    // 同步方法
    public static synchronized void syncMethod() {

        System.out.println("Hello SyncMethod");
    }

    // 同步代码块
    public static void syncCode() {

        synchronized (SyncDemo.class) {

            System.out.println("Hello SyncCode");
        }
    }
}
复制代码

编译成 class 字节码文件后反编译一下

javac SyncDemo.java 
javap -v SyncDemo
复制代码

同步代码块的字节码:

可以看到有 monitorentermonitorexit 两个指令,在执行到 monitorenter 指令时,当前线程会尝试获取锁对象的 monitor(底层是操作系统的Mutex)的持有权,当 monitor 里的计数器为 0 就可以获取成功并将计数器加 1,否则线程挂起,如果已经持有 monitor 的线程再次执行到 monitorenter 指令就将计数器再次加 1,这就是可重入锁的特性;当执行到 monitorexit 指令时,会将计数器减 1,直到计数器为 0 后才会唤醒其他挂起的线程继续争抢锁对象。

你可能发现反编译后的内容里有两个 monitorexit 指令,按理说应该一个 monitorenter 对应着一个monitorexit,为什么会有两个 monitorexit 指令呢?

其实第二个 monitorexit 指令是出现异常的出口,就代表着出现异常后持有当前 monitor 的线程也会释放锁并且将计数器减 1,所以我们在使用 synchronized 时不需要显示的解锁就能保证出现异常也不会死锁

同步方法的字节码:

我们看到反编译后的同步方法在方法上做了一个“ ACC_SYNCHRONIZED ”标记,这个标记和 monitorenter 和 monitorexit 两个指令原理一样,只是同步块进行显示执行指令,而同步方法就相当于在方法开始执行 monitorenter,方法结束执行 monitorexit

上述描述只能证明 synchronized 能保证原子性,那有序性和可见性如何保证呢?

  • 对于有序性,上面分析了 synchronized 修饰的同步方法或同步代码块在同一时刻只能有一个线程可以进入,所以有序性对于其他线程来看是可以保证的(这里的有序性是保证最终结果的正确,但是不能防止指令重排,所以一些会被指令重排的操作比如新建对象就需要加 volatile 关键字保证代码执行不会出错)。

  • 对于可见性,我们在《深入浅出volatile关键字》中说到 volatile 修饰的变量在每次使用时都从主内存获取,修改时都立刻写回主内存,而在 Java 内存模型还有这样两条规定:

1. 对一个变量执行 lock 操作,那将会清空工作内存中此变量的值,在执行引擎使用这个值前,需要重新 load 或 assign 操作初始化变量的值。

2. 对一个变量执行 unlock 之前,必须先把次变量同步回主内存中。

根据以上规定得出在执行到 monitorenter 时(lock),会将所有变量在所有线程的工作内存中清空,使用必须直接从主内存获取,执行到 monitorexit 时(unlock)也会将更新的变量立刻写回主内存,所以 synchronized 也同样可以保证可见性。

综上所述,synchronized 修饰的同步方法和同步代码块是可以保证保证线程安全的。

synchronized 通过内部对象 Monitor(监视器锁)实现的,每个对象内有着存储 Monitor 指针的位置,这个内部对象 Monitor 在各个版本虚拟机中的实现又各不相同,在 Hotspot 中是基于 C++ 的 ObjectMonitor 类实现的,ObjectMonitor 里维护了等待和阻塞的队列,而 Monitor 最终又是依赖操作系统的 Mutex lock(互斥锁)实现,而申请操作系统底层的 Mutex lock 有需要进行用户态到内核态的切换,并且需要对线程进行挂起和唤醒,上面也讲到了现在主流的Java虚拟机中线程都是和内核线程 1:1 实现的,所以对线程的调度和申请锁都需要用户态转内核态。显而易见在这种没有任何优化的情况下使用 synchronized 去保证线程安全开销是巨大的

但是到了Java 1.6 之后,Java 在 JVM 层面对 synchronized 增加了许多优化:偏向锁,轻量级锁,自适应自旋锁,锁消除,锁粗化。

加入了偏向锁和轻量级锁后,锁就有四种状态级别由低到高:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

偏向锁

  • 在很多情况下,锁不存在多线程的竞争,并且一直是由一个线程获取,为了减少没有线程竞争也需要申请锁的耗时操作,JVM 对 synchronized 加入了偏向锁的优化。最开始锁对象中的 Mark Word 是无锁状态,当有一个线程进入同步块时会通过 CAS(

    拿预期值和实际值比较,如果符合就进行更新

    )的操作将当前线程的线程 ID 记录在对象头的 Mark Word 里,如果成功就将 Mark Word 的锁标志位改为偏向模式,这样就代表竞争成功线程可以进入代码块中执行,后续如果这个线程再次尝试获取该锁对象,就判断 Mark word 里锁标志位是否为偏向模式且线程 ID 是否是指向当前线程,如果是无需 CAS 操作就可以直接进入代码块中执行;如果最开始 CAS 操作修改线程 ID 的操作失败,就代表有竞争,锁会升级成轻量级锁

到这里,你可能会问,什么场景下才会应用到偏向锁这种优化呢?

  • 其实在Java 1.6之前,很多线程安全的集合(Vector,HashTable等)都是对整个方法进行加锁,大部分时候都是没有线程竞争的,所以为了对这大粒度锁的优化就有了没有竞争就不需要申请互斥锁的偏向锁。不过这种偏向锁也加大了 HotSpot 虚拟机其他组件的复杂度,后面又有了许多高性能的线程安全集合比如 ConcurrentHashMap 、CopyOnWriteArrayList ,总结下来其实就是 ROI (投资回报率)太低了,所以随着JDK 15 发布,这个特性已经被官方废弃,想要使用需要通过 JVM 参数手动启用。

轻量级锁

  • 轻量级锁的思想是用自旋等待的方式去获取锁减少线程挂起和唤醒带来的开销。但是自旋等待也是有开销的,所以适合两个线程近似交替进行的场景。当偏向锁加锁失败后锁会升级成轻量级锁。虚拟机首先在当前线程的栈帧中创建一个 Lock Record(锁记录) 的空间(重入锁的情况下重入一次就新建一个 Lock Record),然后将 Mark Word 拷贝一份存进 Lock Record 中,然后用CAS的操作去将 Mark Word 里的更新为指向 Lock record 的指针,如果 CAS 操作成功,代表成功获取到锁,就可以进入同步代码块中执行,如果 CAS 操作失败,就会进入自旋等待状态不断去尝试获取锁,当然自旋也是一个耗时的操作,默认如果自旋超过 10 次锁就会再次膨胀成为重量级锁。

拷贝这个Mark Word有什么用处?

  • 拷贝Mark Word 到 lock record 里是为了后续的解锁,解锁是通过CAS的操作将 lock record 中的副本 Mark Word 与 Mark Word 进行置换,置换成功就代表解锁成功,置换失败代表锁已经膨胀需要进行重量级锁的流程。

  • 针对自旋,Java 1.6 同样有新的优化:自适应自旋锁。简单来说就是在自旋的基础上根据该锁上一次的自旋时间长短以及锁的拥有者状态决定本次的自旋次数,来减少无效的自旋开销。如果上一次线程自旋时间很短,虚拟机认为该锁的同步时间很短,可以适当增加本次自旋次数;如果上一次自旋时间很长基本上要膨胀为重量级锁了,虚拟机就会认为是无效自旋,直接跳过。

偏向锁和轻量级锁都是典型的乐观锁,为了减少向操作系统申请锁和挂起唤醒线程带来的开销,重量级锁是典型的悲观锁。随着锁的级别越高,开销也就越大,JVM锁膨胀机制就可以在保证程序线程安全性的情况下选择开销最小的锁。

锁消除与锁粗化

在说锁消除前先说下逃逸分析的概念

逃逸分析

逃逸分析是 JVM 一种分析优化技术,判断一个变量是否逃逸,取决于该变量是否能被外部方法或线程访问得到,而能被外部方法访问到的叫做方法逃逸,被外部线程能访问到的是线程逃逸;对于同方式的逃逸可以采取不同程度的优化。

  • 栈上分配:

我们都知道 Java 是在堆上给对象分配创建内存的,如果能做到线程不逃逸,就可以直接在线程栈上给对象分配内存,对象所占用的内存空间就会随着栈帧出栈而销毁,大量对象就会随着方法的结束而自动销毁了,垃圾收集的压力自然就会小很多。

  • 标量替换:

如果一个数据已经无法再分解成更小的数据(原始数据比如 int ,long 等)来表示了,那么这些数据就可以被称为标量,而把一个 Java 对象拆开替换成更小的原始数据来访问的过程就叫做标量替换。所以如果对一个对象逃逸分析出方法不逃逸,那么程序执行时就直接在栈上创建需要使用的原始数据即可。

锁消除

  • 锁消除是指虚拟机的即使编译器在运行时检测到某段需要同步的代码不存在共享数据的竞争而做的一种对锁进行消除的优化策略。这个判断主要是根据逃逸分析来实施。在同步代码块中,如果操作的变量不逃逸,JVM 就会自动把锁消除掉。

锁粗化

  • 锁粗化比较好理解些,就是在一个方法内多次获取锁,JVM会自动合并成一个锁,减少每次都要加锁的开销。

可以看得出,Java 官方对 synchronized 真的情有独钟,用了非常多乐观锁的思想去优化 synchronized 来减少申请锁和线程切换带来的开销。

四. 总结

本篇文章讲了 Java 线程的实现、线程的调度,又讲了 synchronized 是如何保证线程安全和底层实现原理,以及Java 1.6 对 synchronized 的优化:锁升级机制、锁粗化、锁消除的原理等。虽然本篇文章很八股,但是这些也有助于日常开发中对线程安全的考虑和性能优化带来新的解决思路(本来想把 lock 的分析也写在这里,但是篇幅过长,后面会专门写一篇来分析 lock 源码和实现原理)。

如果觉得文章不错可以点个赞和关注**!**

公众号:阿东编程之路

你好,我是阿东,目前从事后端研发工作。技术更新太快需要一直增加自己的储备,索性就将学到的东西记录下来同时分享给朋友们。未来我会在此公众号分享一些技术以及学习笔记之类的。妥妥的都是干货,跟大家一起成长。

参考书籍及文章:

1.《深入理解Java虚拟机》 作者:周志明

2. openjdk.java.net/jeps/374

猜你喜欢

转载自juejin.im/post/7129866237244342280