likes
comments
collection
share

synchronized详解

作者站长头像
站长
· 阅读数 40

synchronized 是 Java 中的一个关键字,是阻塞式的同步锁。它能保证至多只有一个线程能够获得对象锁。它具有可见性、有序性、原子性。

  • 可见性:

即线程对一个共享变量的修改对于其他线程是可见的。CPU的读写速度远大于内存,为了提高效率,CPU在读写完成后,会现将数据存在中间缓存中,然后再存储在内存中。在 Java 中,线程也有自己的本地内存,这样可能出现这样一个问题,线程A读取了一个变量并修改了它,但是只是放在了本地内存中并未放入主内存中,这时另一个线程B读取到的值是未修改的变量。可见性就是能够看到共享变量的修改,从而避免该问题的发生。

  • 有序性:

为了优化程序的性能,编译器会进行指令重排的优化。指令重排也就是将指令(代码)重新排列,为了让CPU乱序执行。指令一乱,可能就会影响我们程序的正常运行了。有序性则保证了指令的顺序执行。为什么平时不使用多线程不用考虑指令重排呢?因为指令重排的原则as-if-serial,无论怎么指令重排都不可影响单线程的运行结果。

  • 原子性

原子性就比较好理解了,也就是所谓的原子操作,即在这个代码块中,所有的操作都是原子的,可以理解为n条代码变为一条代码执行,n那么结果就是要么全部成功,要么全部失败。

一、synchronized的基本使用

synchronized保证了只有一个线程能够获得锁,其他未获得锁的线程将进入阻塞状态,在不同的使用方法下,这个锁对象也是不同的。

1、修饰实例方法

public synchronized void add(){
       i++;
}

此时锁对象是当前的实例对象--this

2、修饰静态方法

public static synchronized void add(){
       i++;
}

此时锁对象是该方法所在类的class对象。

3、修饰代码块

public void add(){
    synchronized(obj){
       i++;
    }
}

此时锁对象是传入的对象,该对象可以是任意一个对象,在第一种--修饰实例方法中,其实等价于将该obj改为this;在第二种--修饰静态方法中,其实等价于将obj改为该方法所在类的class对象。

现在我们已经会synchronized的基本使用了,接下来就让我们探究原理和更深层次的技术了。

二、对象的内存模型

我们 new 过很过对象了,但是Java对象的内存模型你有了解过吗?

为了防止硬件、操作系统等内存的差异,让 Java 程序在不同系统上保证同样的效果,JVM 规定了 Java 对象的内存模型。Java 对象由对象头、实例数据和对齐填充(填充数据)组成。

以下为64位JVM中,对象的内存模型:

synchronized详解

对象头是我们讨论的重点,在对象头的Mark Word中存储了对象的运行时数据,如hash码、分代年龄以及锁信息,以下是64位JVM中对象头中Mark word的几种情况:

|--------------------------------------------------------------------|--------------------|
| Mark Word (64 bits)                                                |        State       |
|--------------------------------------------------------------------|--------------------|
| unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:0 | 01    |        Normal      |
|--------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2     | unused:1 | age:4 | biased_lock:1 | 01    |        Biased      |
|--------------------------------------------------------------------|--------------------|
|                          ptr_to_lock_record:62             | 00    | Lightweight Locked |
|--------------------------------------------------------------------|--------------------|
|                          ptr_to_heavyweight_monitor:62     | 10    | Heavyweight Locked |
|--------------------------------------------------------------------|--------------------|
|                                                            | 11    |      Marked for GC |
|--------------------------------------------------------------------|--------------------|

特别注意,Mark Word使用了两个bit位记录线程的状态。

  • 当对象为普通对象时,Mark Word 中 biased_lock 为 0,锁的状态标记为 01。
  • 当对象为偏向锁时,Mark Word 存储了偏向线程的 ID,biased_lock 为 1,锁的状态标记为 01。
  • 当状态为轻量级锁时,Mark Word 存储了指向 Lock Record(锁记录),锁的状态标记为 00。
  • 当状态为重量级锁时,Mark Word 存储了指向的 Monitor 对象,锁的状态标记为 10。

上面说到,当状态为重量级锁时,Mark Word存储了指向的Monitor对象,那Monitor对象是什么呢?

当用 synchronized 修饰方法时,会给方法加上标记 ACC_SYNCHRONIZED,JVM 就知道这个方法是一个同步方法,于是在进入同步方法的时候就需要执行获取锁的操作,只有拿到锁才能执行该方法。用 synchronized 修饰的代码块,编译后的字节码会有 monitorentermonitorexit 指令,分别对应的是获得锁和解锁。

每个对象都有一个 Monitor 对象与之关联。执行 monitorenter 指令就是线程试图去获取 Monitor 的所有权,抢到了就是成功获取锁了;执行 monitorexit 指令则是释放了Monitor的所有权。

在HotSpot虚拟机中,Monitor是基于C++ 的 ObjectMonitor 类实现的,其主要成员包括:

  • _owner:指向持有 ObjectMonitor 对象的线程
  • _WaitSet:存放处于 wait 状态的线程队列,即调用 wait() 方法的线程
  • _EntryList:存放处于等待锁 block 状态的线程队列
  • _count:约为WaitSet 和 _EntryList 的节点数之和
  • _cxq: 多个线程争抢锁,会先存入这个单向链表
  • _recursions: 记录重入次数

ObjectMonitor的基本工作机制:

synchronized详解 当多个线程同时访问一段同步代码块时,只有一个线程会获取到Monitor 对象锁,并将Monitor中的 _owner 变量设置为当前线程,同时Monitor中的计数器 _count 加1。其他线程进入_EntryList 队列中。

若持有Monitor的线程调用 wait() 方法,将释放当前持有的Monitor,_owner变量恢复为null,_count自减1,同时该线程进入 _WaitSet 集合中等待被唤醒。在_WaitSet 集合中的线程会被再次放到_EntryList 队列中,重新竞争获取锁。

若当持有Monitor的线程执行完毕,也将释放Monitor并复位变量的值,以便其他线程进入获取锁。

三、锁的升级

在此之前,我们先了解一下用户态和内核态。

用户态,只能访问应用程序,对硬件是没有直接控制权限的,也不能直接访问地址的内存,程序只能是通过调用系统接口访问硬件和内存。

内核态,有对硬件的所有操作权限。

在重量级锁下,多个线程去争夺 Monitor 对象锁,而在加锁的过程中,Monitor 会调用一些内核函数,这必然会造成大量的用户态与内核态的转换,需要保留用户态的上下文信息、寄存器等,复制用户态参数在内核态执行,复制内核态执行结果返回给用户态,恢复用户态的执行......需要大量的开销,影响程序的性能。

JDK1.6 中引入偏向锁和轻量级锁进行了优化,在一定程度上提高了锁的性能。

1、轻量级锁

如果一个对象虽然有多线程需要加锁,但是他们没有竞争,这个时候可以使用轻量级锁来优化,减少重量级锁使用操作系统互斥量产生的性能消耗。

首先在线程的栈帧中创建锁记录(Lock Record)对象,让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录。

如果 cas 替换成功,对象头中存储了锁记录地址和状态 00 ,表示由该线程给对象加锁。

当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头。成功,则解锁成功,失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程。

当有其他线程想要获取该锁(竞争),通过一次cas操作必然失败,这时将有两种情况。

锁膨胀

线程 t1 在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

线程 t1 为锁对象申请 Monitor 锁,让锁对象的Mark Word指向重量级锁地址,然后自己进入 Monitor 的 _EntryList

锁自旋

自旋锁会假设在过一小段时间后,当前的线程可以获得锁,当前竞争的线程会自旋重试,不断的尝试获取锁。如果持锁线程退出了同步块,释放了锁,这时当前线程就可以避免阻塞。如果在多次自旋还未获得锁,那同样会锁膨胀,变为重量级锁。

自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。 在 Java 6 之后自旋锁是自适应的,比如对象最近的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋。

2、偏向锁

如果只有自己这一个线程,使用轻量级锁每次重入仍然需要执行 CAS 操作。 Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。

3、总结

最后总结一下锁的升级过程。

(1)当没有被当成锁时,这就是一个普通的对象,锁标志位是01,是否偏向锁那一位是0;

(2)当对象被作为同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,并记录线程A的ID,进入偏向锁状态;

(3) 当线程A再次试图来获得锁时,JVM发现同步锁对象是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步中的代码;

(4) 当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是线程B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步代码。如果抢锁失败,则继续执行步骤5;

(5) 偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6;

(6) 轻量级锁抢锁失败,JVM会进行自旋锁,尝试抢锁。如果抢锁成功则执行同步代码,如果失败则继续执行步骤7;

(7) 自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。

转载自:https://juejin.cn/post/7237037303914086437
评论
请登录