Synchronized
synchronized
synchronized可以用来对程序加锁,synchronized内的代码又叫做同步代码块,它既可以用来声明一个synchronized代码块,也可以直接标记静态方法或者实例方法。
当声明 synchronized 代码块时,编译而成的字节码将包含 monitorenter
和 monitorexit
指令。这两种指令均会消耗操作数栈上的一个引用类型的元素(也就是synchronized 关键字括号里的引用),作为要加锁解锁的对象。
当查看使用了synchronized 关键字的代码编译过后的字节码时,可以发现会存在一个monitorenter
和多个monitorexit
,这是因为虚拟机需要保证加锁之后,不论是正常执行还是异常执行,都需要能够释放锁。
当用synchronized 标记方法时,虚拟机在进入该方法后进行monitorenter
,而退出方法时,不论是正常结束还是抛出异常,都需要进行monitorexit
。这里的monitorenter
和monitorexit
操作所对应的锁对象是隐式的。
对于实例方法来说,这两个操作对应的锁对象是this,即该实例本身,而对于静态方法来说,这两个操作对应的锁对象是所在类的Class实例。
monitorenter 和 monitorexit
关于 monitorenter
和 monitorexit
的作用,我们可以抽象地理解为每个锁对象拥有一个锁计 数器和一个指向持有该锁的线程的指针。当执行monitorenter
时,如果锁对象的计数器为0,则说明他没有被其他线程所持有,虚拟机会将该锁对象的持有线程设置为当前线程,并将计数器加1。如果计数器不为0,那么判断持有锁的线程是否是当前线程,如果是则加1,否则需要等待持有锁的线程释放锁。当执行 monitorexit
时,Java 虚拟机则需将锁对象的计数器减 1。当计数器减为 0 时,那便 代表该锁已经被释放掉了。
重量级锁
在这种状态下,Java 虚拟机会阻塞加锁失败的线程,并且在目标锁被释放的时候,唤醒这些线程。而Java线程的阻塞和唤醒,都是需要依赖于操作系统来完成的。对于符合posix接口的操作系统,这些操作是通过pthread的互斥锁(mutex)来实现的,这些操作都涉及到系统调用,需要从用户态切换至核心态,开销很大。
为了避免这些切换,Java虚拟机在线程进入阻塞状态前,以及被唤醒后竞争不到锁的情况下,会进入自旋状态,在处理器空跑并且轮询锁是否被释放。如果此时被释放,则不需要进入阻塞状态,直接获取锁。
与线程阻塞相比,自旋可能会浪费大量CPU资源。这里自旋并非一定会持续下去,虚拟机采用的是自适应自旋,根据以往自旋等待时是否能够获得锁,来动态调整自旋的时间(循环数目)。
自旋还会导致一个问题,即处于阻塞状态的线程无法立刻竞争这把锁,而处于自旋状态的可能会优先获取锁,也就是说并不公平。
轻量级锁
多个线程在不同的时间段请求同一把锁,也就是说没有锁竞争。针对这种情形,Java 虚拟机采用了轻量级锁,来避免重量级锁的阻塞以及唤醒。
如何区分轻量级和重量级锁
Java对象头中有一个标记字段,它的最后两位便被用来表示该对象的锁状态,00代表轻量级锁,01代表无锁或偏向锁,10代表重量级锁,11则跟垃圾回收算法的标记有关。
加锁和释放锁
在进行加锁时,虚拟机会判断是否已经是重量级锁,如果不是,它会在当前线程的栈帧中划出一块空间,作为该锁的锁记录,并且将锁对象的标记字段复制到该锁记录中。
然后,虚拟机会尝试使用CAS操作替换锁对象的标记字段,先判断标记为是否为01,如果是,则替换为刚刚分配的锁记录的地址。
注意,上述整个过程可以理解为一个CAS操作。当进行加锁时,判断对象标记为并不是重量级锁,那么就先在当前线程的栈帧中开辟空间,然后将对象的标记字段复制到栈帧中,之后是采用CAS对比栈帧中的标记字段和对象的标记字段是否相同,如果相同则将对象的标记字段替换为刚刚分配栈帧中的地址。然后加锁就成功了。这里,是将标记字段替换为了线程私有栈帧中的地址。
如果比较不相同,则有两种可能:
第一是该线程重复获取同一把锁,这时候虚拟机会将锁记录清零,一戴白哦该锁重复获取。
第二是有其他线程持有该锁,那么虚拟机将会把这把锁膨胀为重量级锁,并阻塞当前线程。
当进行解锁操作时, 如果当前所记录的值为0,则代表重复进入同一把锁,直接返回即可。(可以将线程的所有所记录想象为一个栈结构,每次加锁压入一条所记录,解锁弹出一条锁记录,当前锁记录指的就是栈顶的锁记录)
如果不是0,则虚拟机会尝试使用CAS操作,比较锁对象的标记字段的值是否为当前锁记录的地址,如果是,则替换为锁对象原本标记字段的值(该值在加锁时被复制到了线程私有的栈帧中,然后将值替换为具体的地址,这里是通过地址将原先的值复制回来)。然后释放锁成功。
如果不是该地址,说明锁被升级为重量级锁,此时需要进入重量级锁的释放流程,唤醒因竞争该锁而被阻塞的线程。
这里,可能是该线程之前加锁成功,在加锁过程中有另外的线程试图获取锁,或者在加了轻量级锁后有线程试图加锁,那么该锁会被升级为重量级锁。
偏向锁
在线程进行加锁时,如果该锁对象支持偏向锁,那么 Java 虚拟机会通过 CAS 操作,将当前线程的地址记录在锁对象的标记字段之中,并且将标记字段的最后三位设置为 101。(这里是将线程的地址写入对应的对象,而轻量级锁是复制对象的内容到线程的栈帧)。
之后,每当有线程请求这把锁,虚拟机只需要判断锁对象标记字段中最后三位是否为101,是否包含当前线程的地址,以及epoch值是否和锁对象的类的eopch值相同。如果都满足,则当前偏向锁就属于该对象,直接返回即可。
eopch
当请求加锁的线程和锁对象标记字段 保持的线程地址不匹配时(epoch值相等,如果不相等,当前线程可以将该偏向锁偏向至自己),虚拟机需要撤销该偏向锁,过程比较麻烦,要求持有偏向锁的线程到达安全点,再将偏向锁替换成轻量级锁。如果某一类的锁对象撤销次数超过一个阈值(对应 Java 虚拟机参数 - XX:BiasedLockingBulkRebiasThreshold,默认为 20),虚拟机会让该偏向锁失效。
具体的做法便是在每个类中维护一个 epoch 值,可以理解为第几代偏向锁。当设置偏向锁 时,Java 虚拟机需要将该 epoch 值复制到锁对象的标记字段中。当某个偏向锁失效时,会将该类的epoch值加1,表示之前那一代的偏向锁已经失效,新设置的偏向锁需要复制新的epoch值。
为了保证当前持有偏向锁并且已加锁的线程不至于因此丢锁,Java 虚拟机需要遍历所有线程 的 Java 栈,找出该类已加锁的实例,并且将它们标记字段中的 epoch 值加 1。该操作需要所 有线程处于安全点状态。
总撤销次数超过阈值(对应 Java 虚拟机参数 - XX:BiasedLockingBulkRevokeThreshold,默认值为 40),那么虚拟机会认为该类已经不再适合偏向锁,虚拟机会撤销该类的偏向锁,并在后续加锁过程中直接加轻量级锁。