Synchronization and Object Locking

非线程安全主要指多个线程同时对同一个共享变量操作时,出现数据不一致的情况;而线程安全就是为了保证数据一致性。

线程安全主要体现在三个方面:原子性、可见性、有序性;可以使用 synchronized、ReentrantLock、CAS 来保证线程安全。

而 Java 中也自带了一些线程安全的类:

synchronized 关键字

简而言之,在多线程环境中,当两个或多个线程试图同时更新共享数据时,就会发生竞争。Java 通过 synchronized 对共享数据的线程访问提供了一种避免竞争条件的机制,只允许一个线程在任何给定的时间执行。

synchronized 可以用来修饰方法(实例或类)和代码块:

// 代码块
synchronized (ob) {

}

// 可以修饰静态方法和动他方法
public synchronized void test() {
        
}

如果 synchronized 关键字作用在实例方法上,那么它监视的是当前对象(也就是对象锁);如果作用在静态方法上那么它监视的是当前类的 Class 对象(也就是类锁);synchronized 代码块也只能传递对象。使用该关键字可以保证,只有一个线程能够方法代码。

在有些时候会提到临界区,所谓的临界区就是产生了竞争的地方;也就是被锁保护起来的代码块。

方法 flags 和 字节码

这两中方式是 JVM 对 synchronized 的实现。flags 指的是将 synchronized 作用到方法上时,而字节码指的是 synchronized 代码块。

下面执行 javap -v 包名.类名 命令,查看方法中的 flags:

  public synchronized void test();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 42: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       1     0  this   Lcom/**/**/Main;

上面是输出结果,可以看到 flags 中存在 ACC_SYNCHRONIZED,就表示该方法是一个同步方法。

同样可以使用 javap 命令看到 synchronized 代码块的实现方式:

72: monitorenter
73: aload         5
75: monitorexit

代码块的实现方式使用的是两个字节码,monitorenter 表示进入和 monitorexit 表示退出同步代码块。

对于 synchronized 关键字,建议和 Java Monitor 一起阅读,有助于理解。

锁

这里写的锁全部都是针对对象头中保存的锁(这四种锁是指锁的状态,专门针对 synchronized 的);其他的锁例如 ReentrantLock 等,不在这里说。

无锁

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。例如 CAS 就是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。

偏向锁

偏向锁就是为了减少重进入时候的 CAS 操作,这样就降低了获取锁的代价。因为轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。

偏向锁是可以开启和关闭的(默认开启),并且偏向锁会延迟启动,可以通过 -XX:BiasedLockingStartupDelay=0 禁用延迟。当然你也可以使用 -XX:-UseBiasedLocking 关闭偏向锁功能。

这个意思就是说,在偏向锁还没有启动的时候,对象头的第三位(偏向锁标识)是 0;当偏向锁启动了后,对象头的第三位就变成了 1。

Object object = new Object();
System.out.println(ClassLayout.parseInstance(object).toPrintable());

TimeUnit.SECONDS.sleep(4);
        
Object object1 = new Object();
System.out.println(ClassLayout.parseInstance(object1).toPrintable());

下面是 jol-core 的打印

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 00 00 00 (00000101 00000000 00000000 00000000) (5)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

批量重偏向

偏向,只会偏向一个线程,当线程死亡或退出同步代码块时,并不会将 mark word 中的 thread id 改为 0(未偏向)或者 有另一个线程获取锁时,将 thread id 改成当前线程的 id,而是撤销偏向锁后,再升级为轻量级锁。

而批量重偏向,重点是这个批量;也就是说,当撤销偏向锁的阈值到达 20 次时,就会进行重偏向。

假设现在有 30 个锁都偏向 T1 这个线程,然后 T2 线程现在需要用到这 30 个锁,这是会将前 19 个锁都撤销偏向并升级为轻量级锁,从第 20 个开始所有的锁都会偏向 T2 这个线程。

批量撤销

也有有一个阈值是 40,当撤销次数多了后,会把这个类的所有对象都撤销为不可偏向,就算新创建一个这个类的对象,这个对象锁也是不可偏向。

值得注意的是:

  • 当 mark word 保存了某个线程的 ID(偏向了某个线程),即使这个线程释放了锁,也不会将 mark word 中记录的线程 ID 清除掉。而且这个线程 ID 是操作系统分配的线程 ID,而不是 Java 中的线程 ID。
  • 当调用了对象的 hashCode() 方法时,会自动撤销偏向锁;也就是将偏向锁标识设置为 0。这是因为 hashCode 是保存在 mark word 中;而轻量级锁没事,是因为保存到了线程栈中;重量级锁没事,是因为保存到了 monitor 中。

轻量级锁

这个怎么说呢?昂,先说一下重量级锁,重量级锁是通过操作系统 API 实现的(也就是操作系统中的互斥量实现的);而轻量级锁是通过 JVM 实现的,它是为了在没有多线程竞争的情况下,减少重量级锁产生的性能消。

在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。

当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,然后拷贝对象头中的 Mark Word 复制到锁记录中。

拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record 里的 owner 指针指向对象的 Mark Word。

如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。

如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。

若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

指的注意的是:每个线程都会在自身的线程栈中创建锁记录(Lock Record)对象,用来保存对象的 Make Word 和 对象引用。

下图是一个加锁解锁流程图:

讨论

1. 类锁和对象锁的区别

// 对象锁
Object ob = new Object();
synchronized (ob) {
            
}

// 类锁
Class<Object> objectClass = Object.class;
synchronized (objectClass) {

}

通过上面代码可以看出,因为 类名.class 获取的是 Class 对象,所以类锁和对象锁没有本质区别,其实都是对象锁。

参考资料

Lock Record--锁记录

Synchronization and Object Locking

HotSpot Glossary of Terms

java对象在内存中的结构(HotSpot虚拟机)

posted @ 2021-06-01 10:42  代号-G  阅读(122)  评论(0)    收藏  举报