synchronized的优化
synchronized的优化
一、简介
Java SE 1.6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。因此,在 Java SE 1.6 一共有 4 种锁的状态,级别由低到高依次是:无锁、偏向锁、轻量级锁、重量级锁,并且四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,也就是说只能进行锁升级(从低级别到高级别),意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
二、预备知识
2.1 对象头
以 Hotspot 虚拟机为例,对象在堆内存中的存储布局可以分为三个部分:对象头、实例数据和对齐填充,这里我们只需了解对象头即可。Hotspot 虚拟机的对象头主要包括两类信息,分别是:Mark Word 和类型指针。
Mark Word:用于存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,这些信息都是与对象自身定义的数据无关的额外存储成本,所以 Mark Word 被设计成一个动态定义的数据结构以便在极小的空间存储尽量多的数据。此外,它会根据对象的状态复用自己的存储空间,即在运行期间 Mark Word 里存储的数据会随着锁标志位的变化而变化。
类型指针:即对象指向它的类型元数据的指针,虚拟机通过这个指针来确定该对象是属于哪个类的实例。但并不是所有的虚拟机实现都必须在对象数据上保留类型指针,即查找对象的元数据信息并不一定要经过对象本身。此外,若对象是一个数组,则对象头中还必须有一块用于记录数组长度的数据,这是因为无法通过元数据中的信息推断出数组的大小,而虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小。
Mark Word 的结构如下图所示:
以 32 位虚拟机为例,总结下在不同锁状态下,Mark Word 的字节码分配情况。
锁状态 | 字节码分配情况 |
---|---|
无锁 | 25 bit 存储对象的 hashCode,4 bit 存储对象的分代年龄,1 bit 存储是否为偏向锁的标识位 (0),2 bit 存储锁标志位 (01) |
偏向锁 | 23 bit 存储线程 ID,2 bit 存储 Epoch,4 bit 存储对象的分代年龄,1 bit 存储是否为偏向锁的标识位 (1),2 bit 存储锁标志位 (01) |
轻量级锁 | 30 bit 存储指向栈中锁记录 (lock record) 的指针,即 Displaced Mark Word,2 bit 存储锁标志位 (00) |
重量级锁 | 和轻量级锁一样,30 bit 存储指向重量级锁的指针,2 bit 存储锁标志位 (10) |
GC 标记 | 开辟了 30 bit 的空间,但没有使用,最后 2 bit 存储锁标志位 (11) |
2.2 锁状态
锁状态 | 标志位 | 存储的内容 |
---|---|---|
无锁 | 01 | 对象的 hashCode、对象分代年龄,是否偏向锁 (0) |
偏向锁 | 01 | 偏向线程 ID、偏向时间戳、对象分代年龄、是否偏向锁 (1) |
轻量级锁 | 00 | 指向栈中锁记录的指针 |
重量级锁 | 10 | 指向重量级锁的指针 |
2.3 ObjectMonitor
在 JVM 的规范中,每个对象和类在逻辑上都是和一个监视器相关联的,为了实现监视器的排他性监视能力,JVM 为每一个对象和类都关联一个锁,锁住了一个对象,即为获得对象相关联的监视器。这里的监视器就是指的是 ObjectMonitor,其结构如下所示:
还有重要的一点是,Mark Word 中重量级锁指向的重量级指针就是 ObjectMonitor 对象指针,是基于操作系统互斥(mutex)实现的。
三、锁升级
3.1 偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了降低线程获得锁的代价而引入偏向锁。偏向锁即会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
当锁对象第一次被线程获取的时候,虚拟机会将 Mark Word 中的锁标志位设置为“01”、偏向模式设置为“1”。此外,使用 CAS 操作将获取到这个锁的 ThreadID 会被记录到对象的 Mark Word 中。若 CAS 操作成功,则持有偏向锁的线程在以后每次进入这个锁的相关同步块时,线程会判断此时持有锁的线程是否就是自己(持有锁的 ThreadID 也在对象头里),若是则正常往下执行,不必进行任何同步操作。
最重要的是,偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。
此外,关于偏向锁的撤销(偏向模式设置为 0),需要等待全局安全点,即在某个时间点上没有字节码正在执行时。它会先暂停拥有偏向锁的线程,然后判断对象锁是否处于被锁定状态,若未锁定,则恢复到未锁定状态“01”,反之则升级为轻量级锁状态“00”。
还有一种比较特殊的情况是,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要 计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。这是因为对象的一致性哈希码来源于 Object#hashCode() 方法,且该值存储在 Mark Word 中从而保证每次调用该方法都能使哈希码的值不发生改变,而当对象进入偏向状态的时候,Mark Word大部分的空间(23个比特)都用于存储持有锁的线程ID了,这部分空间占用了原有存储对象哈希码的位置。
3.2 轻量级锁
轻量级锁是由偏向锁升级而来的,即当锁还是偏向锁的时,有其他线程加入该锁的竞争,此时却无法获得锁,则偏向锁会升级成轻量级锁,其他线程会以自旋(固定次数自旋和自适应自旋)的方式尝试获取锁,线程不会阻塞。
自旋分为固定次数自旋和自适应自旋。首先,自旋等待不能代替阻塞,自旋等待本身避免了线程切换的开销,但其会占用处理器的时间。所以,自旋效果的好坏取决于获取锁的等待时间,等待时间越短,自旋效果越好。但是,自旋等待的时间也必须有一定的限度,默认固定是 10 次,可通过 -XX: PreBlockSpin 修改。 自适应自旋即自旋的时间不再是固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的,即对于同一个锁对象,若自旋很少成功获得过锁,则后续获取锁的操作直接省略自旋过程,以避免浪费处理器资源;反之,若自旋等待一会就获得过锁,则虚拟机会认为本次自旋也很可能再次获得锁,进而允许更长的自旋时间。
以下有两种情况会形成自旋锁:
关闭偏向锁功能时; 多个线程竞争同一个偏向锁,从而导致偏向锁升级为轻量级锁。 轻量级锁的加锁过程如下:
1)在代码进入同步块的时候,若同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,官方称之为 Displaced Mark Word。
2)拷贝对象头中的 Mark Word 到 Lock Record 中;
3)拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record 里的 owner 指针指向对象的 mark word。如果更新成功,则执行步骤4,否则执行步骤5。
4)若这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为“00”,即表示此对象处于轻量级锁状态。
5)若这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,若是则说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
轻量级锁的解锁过程同样是使用 CAS 操作实现,实现过程如下:
若对象的 Mark Word 仍然指向线程的 Lock Record,那就用 CAS 操作把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来。假如能够成功替换,那整个同步过程就顺利完成了;若替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。
3.3 重量级锁
重量级锁由轻量级锁升级而来,指后续线程尝试获取锁该时,发现被占用的锁是重量级锁,则直接将自己阻塞挂起(而不是忙等),等待将来被唤醒。
此外,重量级锁是依赖对象内部的 monitor 锁来实现的,而 monitor 锁又依赖操作系统的 Mutex Lock (互斥锁) 来实现,而操作系统的线程调度和线程状态变更需要从用户态切换到核心态,这会消耗大量的系统资源。
四、锁粗化
同步块的作用范围应该尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
但是若存在一系列的连续加锁解锁操作,可能会导致不必要的性能损耗。 而锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。
五、锁消除
Java 虚拟机在即时编译时,通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。