基本概念
临界区
竞态条件
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。
上面讲解了临界区和竞态条件,为了避免临界区的静态条件发生,主要有两种类型的解决 方式
- 阻塞式的解决方案,比如 Synchronized, Lock
- 非阻塞式的解决方案:原子变量,cas 操作
下面就主要讲解 Synchronized 关键字的作用。
线程安全分析
成员变量和静态变量的安全性
- 如果没有共享,线程安全
如果共享了
局部变量时线程安全的
局部变量引用的对象不一定
Synchronized 的作用是使用对象锁来保证临界区内代码的原子性,临界区内的代码对外是不可以分割的【需要注意的是即使加了锁,依然还是会发生线程的上下文切换,但是没有拿到锁的对象是不会进入临界区代码执行,所以最终还是只有拿到锁的线程切换到临界区代码执行。】
原理
对象头
32 位的虚拟机的对象头
其中 Mark Word 中存储的内容如下:
可以看到最后两个字节位可以表示加锁的状态最开始没有加锁的时候,最前面的 25 位表示 hashcode, 最后两位是 01
- 当有线程信息的时候,最前面的 23 位保存的是线程信息
- 当加上了 轻量级锁的时候,最后面的两位是 00
- 当加上重量级锁的时候最后面两位是 10
- 当发生 GC 的时候最后面两位是 11
Monitor 对象
每一个 Java 对象都可以关联都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁,那么该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针,Monitor 结构如下所示:
- 刚开始的时候 Monitor 中 Owner 为 null
- 当 Thread-2 线程执行 synchronized(obj) 的时候就会将 Monitor 的所有者 Owner 置为 Thread-2, Monitor 中只能有一个 Owner
- 在 Thread-2 上锁的过程中,如果 Thread-3, Thread-4, Thread-5 也来执行 Synchronized(obj), 就会进入 EntryList Blocked
- 在 Thread-2 执行完同步代码块的内容之后,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时候是非公平的
- 图中 WaitSet 中的 Thread-0,Thread-1 是之前已经获得过锁,但是条件不满足而进入到 WAITTING 状态的线程。
注意:如果对象没有加上 synchronized 那就对象就不会关联 Monitor 监视器锁,也就没有上面的规则。
synchronized 的原理
当代码执行到 synchronized 锁住的方法或者代码块的时候对应的字节码指令是 monitorenter, 这个指令的作用就是将锁住的对象头中的mark word 置为 Monitor 监视器对象的指针,此时就让该锁对象关联了 Monitor 对象,就会遵守上面讲的哪些规则了。
synchronized 原理进阶
轻量级锁
- 如果一个对象虽然有多个线程要加锁,但是加锁的时间是错开的(比如说线程1执行完了,线程2才过来,这种情形就是没有锁竞争),那么此时就可以使用轻量级锁来优化。
- 轻量级锁的语法依然是 synchronized
下面的示例是两个方法同步块,领用同一个对象加锁。
static final Object obj = new Object();
public static void method1() {
synchronized (obj) {
// 同步块A
method2();
}
}
public static void method2() {
synchronized(obj) {
// 同步块B
}
}
代码运行加锁的过程如下:
- 首先在线程的栈帧中创建锁记录(Lock Record)对象,内部可以存储锁定对象的 MarkWord
- 将锁记录中 Object reference 指向锁对象,并尝试使用 cas 替换 Object的 Mark Word。将 Mark Word 的值存入锁记录。
- 如果 cas 替换成功,对象头中存储了 锁记录 地址和状态 00,表示由该线程给对象加锁。
- 如果 cas 失败,有两种情形
- 如果是其他线程已经持有了该 Object 的轻量级锁,此时表示由竞争,进入锁膨胀过程(这个过程在后面看)。
- 如果是自己执行了 synchronized 锁重入,那么再增加一条 Lock Record 作为重入的计数(下面就继续看这种情况)。
- 当退出 synchronized 代码块时如果有取值为 null 的锁记录。表示有重入,此时可以删除改锁记录,表示重入数减一。
当退出 synchronized 代码块时如果锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头。
当 Thread-1 进行轻量级加锁的时候,Thread-0 已经对该对象加了轻量级锁。
- 此时 Thread-1 加轻量级锁失败,进入锁膨胀流程
- 为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址。
- 然后自己进入 Monitor 的 EntryList BLOCKED
- 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word的值恢复给对象头失败(由上图可知,此时对象头里面已经是保存了 Monitor 地址,而不是 Lock Record 地址)。这时就会进入重量级解锁流程,按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中的 BLOCKED 线程。
自旋优化
- 重量级锁竞争的时候,可以使用 自旋来进行优化,如果当前线程自旋成功(也就是这时候持有锁的线程已经退出了同步块,释放了锁,此时当前线程就可以避免阻塞)
- 自旋会占用 cpu 时间,单核 cpu 自旋就是浪费,多核的 cpu 自旋才可以发挥优势。
- java6 之后自旋是自适应的。
- java7 之后不能控制是否开启自旋。
偏向锁
轻量级锁在没有竞争时(比如就自己一个线程在执行),每次重入的时候依然还是要进行 cas 操作。在 java6 之后引入了 偏向锁来进一步进行优化:只有第一次使用 cas 将线程id设置到对象的 Mark Word 之后,之后发现这个线程ID 是自己的就表示没有竞争,不用重新 cas。 以后只有没有发生竞争,那么这个对象就归该线程所有。
比如下面的代码
static final Object obj = new Object();
public static void m1() {
synchronized (obj) {
// 同步块A
m2();
}
}
public static void m2() {
synchronized(obj) {
// 同步块B
m3();
}
}
public static void m3() {
synchronized(obj) {
// 同步块C
}
}
偏向状态
再看一下 64 为的对象头格式
- 之前说的是最后两位记录的是锁的状态,比如 01 表示没有上锁,00 是轻量级锁等。
- 看上图会发现,state 为 normal 和 Biased 的最后两位都是 01,但是倒数第三位是不一样的,倒数第三位为1表示偏向锁,为0 表示没有加锁。
对象创建的过程
- 如果开启了偏向锁(默认是开启的),那么对象创建之后,mark word的值最后三位是 101, 此时它的thread、epoch、age都是0.
- 偏向锁默认是延迟的,不会在程序启动时立即生效,如果想要避免延迟可以加上 VM 参数 xx::BiasedLockingStartupDelay=0
如果没有开启偏向锁,那么对象创建之后,markword 值的最后三位是 001,此时它的 hashcode,age都是0,第一次用到 hashcode 时才会赋值。
撤销-调用对象hashcode
调用了对象的 hashcode,但是偏向锁的对象 MarkWord 中存储的是线程的id,所以调用 hashCode 之后会导致偏向锁被撤销。
轻量级锁会在锁记录中记录 hashCode
- 重量级锁会在 Monitor 中记录 hashCode
撤销-其他线程使用对象
- 当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁。
批量重偏向
- 如果对象虽然被多个线程访问,但是没有竞争,这是偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID.
- 当撤销偏向锁阈值超过 20 次后,jvm会觉得有可能偏向错了,此时会给这些对象加锁时重新偏向到加锁线程。
批量撤销
- 当撤销偏向锁阈值超过 40 次之后,jvm 会觉得此时不应该偏向了,于是,整个类的对象都会变成不可偏向,新建的对象也是不可偏向的。
锁消除和锁粗化
https://zhuanlan.zhihu.com/p/359950338
ReentrantLock
https://www.yuque.com/docs/share/cada84ec-126c-4ee1-a2a6-56a04b6b9223?#