synchronized 的三种使用方式

  • 加在非 static 方法上 (锁的是 this 对象)
  • 加在 static 方法 上 (锁的是 Class 对象)
  • 代码块 (锁的是 括号 中的对象)

在实现同步的时候, 大多数开发人员直接使用 synchronized 关键字, 那你真的了解 synchronized 底层原理吗?

字节码层级

synchronized被编译成 class 文件, 翻译成字节码指令有两个重要的指令 : monitorenter monitorexit , 可以发现有两个 monitorexit, 一个是正常退出, 另一个是异常退出, 所以synchronized 不会造成死锁

使用 idea 插件, 可以查看字节码指令

swift 获取异步主线程 swift synchronized_swift 获取异步主线程

JVM层级

我们知道 synchronized 锁的是对象, 那么如何判断对象是否 “锁住” 呢?

synchronized 使用的锁是存在对象的 对象头 之中

JVM 规范有这样一句话 : JVM 基于进入和退出 monitor 实现代码同步, 任何对象都有一个 monitor 与之关联, 当 monitor 被持有后, 它将处于锁定状态

操作系统层级

在 JDK1.6 之前, synchronized 是重量级锁, Java 进程是工作在用户态空间上的, 如果需要实现同步, 就必须使用内核的互斥锁, 那就需要 OS 从用户态切换到内核态 (使用 0x80 指令切换到内核态)

用户态&内核态

Intel 的 CPU 为 x86 架构, 分为四个指令级别 : ring0 ~ ring3, 而 linux 内核就是说工作在 ring0 指令级别, 拥有本台计算机所有的访问权限, 而普通的应用程序工作在 ring3 指令级别, 只与部分访问权限

synchronized 的底层实现

如今的 HotSpot 采用的是解释器和编译器并存的架构, 可以通过 JIT 即时编译期生成的汇编指令来查看 synchronized 底层是如何实现的

public class Test {
    private static synchronized void test1() { }
    public static void main(String[] args) {
        for (int j = 0; j < 99999; j++) {
            test1();
        }
    }
}

使用 java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly Test 指令生成汇编代码, 找到 test1 方法, 可以看到 lock cmpxchg 指令

对象的内存布布局

对象头

由 Mark Word 和 kclass pointer 组成

Mark Work : 在 32 位和 64 为虚拟机中分别占 4个, 8个字节, 存储对象的哈希码, GC分代年龄, 锁状态标志, 线程持有的锁, 偏向线程 ID. 偏向时间戳等…

kclass pointer : 默认是开启压缩的, 压缩后占4个字节, 指向该实例对象的类元信息,它是一个 c++ 的对象

如果为数组对象, 对象头应该还包含一块记录数组长度的数据

实例数据

对象的实例数据

存储该对象的成员变量

对其填充

HotSpot 严格规定 : 一个对象必须占用 8 个字节的倍数, 该部分只起到占位的作用

使用 OpenJDK 提供的工具 jol, 可以查看对象的内存布局, 如下图, 可以看到, 对象头占 12 个字节 (我使用的是 64 位虚拟机, 如果是 32 位, 对象头占 8 个字节)

swift 获取异步主线程 swift synchronized_多线程_02

锁的升级

JDK 1.6 开始, HotSpot 开发团队实现了锁优化 : 偏向锁, 轻量级 (自旋锁), 大大提高了并发效率

偏向锁

一个线程第一次进入到同步块时, JVM使用 CAS 将对象的 Mark Word 的偏向状态, 将 0 改为 1 同时将 Mark Wrod 的线程 id 指向该线程 id, 如果这个操作成功, 持有偏向锁的线程每次进入这个锁相关的同步块时, JVM 都不会进行任何同步操作, 一旦出现另一个线程去尝试获取这个锁的情况, 偏向模式立刻结

偏向锁用于有同步但无竞争的程, 但是其效率平不一定比轻量级锁高, 在有竞争的情况下, 偏向锁一定会被撤销, 这个过程也是消耗资源的

JVM 启动的时候, 存在明显的线程竞争 (加载 class 文件), 所以默认启动时不会立刻打开偏向锁, 过一段时间才会打, 可以通过 JVM 参数设置
//关闭延迟开启偏向锁
-XX:BiasedLockingStartupDelay=0

轻量级锁

当代码即将进入同步块的时候, JVM 首先会在本线程的栈帧中创建一个锁记录空间 (Lock Record) , 用于存储 Mark Word 的一个拷贝 (官方命名为 Displaced Mark Word), 接着利用 CAS 将对象的 Mark Word 修改为指向锁记录的指针, 如果这个操作成功了, 则说明这个线程获取到了这个对象的锁, 同时对象的 Mark Word 的锁标志位会被修改为 " 00 ", 表示这个对象处于轻量级锁状态

轻量级锁是是通过 CAS 操作避免了传统的重量级锁的使用而减少系统互斥量产生的性能消耗, 如果明显存在锁的竞争, 不仅会产生互斥量, 同时也会进行CAS操作, 相对而言比传统的重量级锁开销更大

重量级锁

如果发现对象处于轻量级锁的状态, 并且锁已被持有, 那么线程需要自适应自旋去获得锁, 当自旋次数超过一定次数时, 或者 自旋线程 > CPU 核数一半, 轻量级锁不在有效, 锁膨胀升级为重量级锁, 对象的锁标志位变为 " 10 ", 此时 Mark Word 存储的就是指向重量级锁 (互斥量) 的指针, 后面等待锁的线程也必须立刻阻塞

当持有偏向锁的线程调用 wait() 过长, 锁也会直接升级为重量级锁

当锁为重量级锁, 如果此时有多个线程来竞争锁, OS 会把这些线程放入ObjectMonitor 的 waitSet 队列中, 供OS调度

为什么有了轻量级锁还要用重量级锁?

当线程数一多, 自旋的线程也会随之变多, 我们知道自旋是消耗CPU的, 所以当线程超过一定限制, 就会严重影响 CPU 性能, 此时必须使用重量级锁

为什么不直接上重量级锁?

如果直接上重量级锁, 需要向 OS 申请互斥锁, 会使 OS 在用户态到内核态之中切换, 在涉及到线程上下文切换的时候非常耗费资源

为什么线程的上下文切换会非常耗费资源?

上下文切换是指 CPU 的控制权由运行状态的线程转换到就绪状态的线程所发生的事件;该操作会保存当前线程的执行现场, 同时载入接下来要执行的线程的执行现场, 这个过程免不了一些寄存器, 缓存之间数据的拷贝, 这个过程并不是轻量级的操作.