5.synchronized

109 阅读11分钟

一、前言:

在前文中我们介绍了解了JMM内存模型,也了解到多线程情况下会出现线程不安全问题。造成这一问题的主要原因还是线程缓存问题及重排序导致的可见性问题。线程拥有自己的栈空间,多个线程共享主内存中的共享变量,线程间通过共享变量达到线程通信的目的。那么就会带来一个问题,如何保证共享变量的线程安全问题?最简单的想法就是每个线程依次读写共享变量,保证每个线程所操作的都是当前最新版本的数据,这样就不会有安全问题。 但是这种同步机制效率很低。

二、synchronized原理

synchronized使用方法

使用位置作用范围被锁的对象示例代码
方法实例方法类的实例对象public synchronized void method() { .......}
静态方法类对象public static synchronized void method1() { .......}
代码块实例对象类的实例对象synchronized (this) { .......}
class对象类对象synchronized (SynchronizedScopeDemo.class) { .......}
任意实例对象object实例对象objectfinal String lock = "";synchronized (lock) { .......}

2.1 对象锁(monitor)机制

先来分析一段代码

public class SynchronizedDemo {
    public static void main(String[] args) {
        synchronized (SynchronizedDemo.class) {
            System.out.println("hello synchronized!");
        }
    }
}

上述代码通过synchronized锁住当前类对象进行同步,将java代码进行编译后通过javap -v SynchronizedDemo.class查看对应的main方法字节码如下:

public static void main(java.lang.String[]);

•    descriptor: ([Ljava/lang/String;)V
​
•    flags: ACC_PUBLIC, ACC_STATIC
​
•    Code:
​
•      stack=2, locals=3, args_size=1
​
•         0: ldc           #2                  // class com/codercc/chapter3/SynchronizedDemo
​
•         2: dup
​
•         3: astore_1
​
•         4: **monitorenter**
​
•         5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
​
•         8: ldc           #4                  // String hello synchronized!
​
•        10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
​
•        13: aload_1
​
•        14: monitorexit
​
•        15: **goto**          23
​
•        18: astore_2
​
•        19: aload_1
​
•        20: **monitorexit**
​
•        21: aload_2
​
•        22: **athrow**
​
•        23: **return

需要通过monitorenter指令获取到对象的monitor(对象锁)后才能往下执行,处理完对应的内部逻辑后通过monitorexit指令来释放所持有的monitor,以供其他并发实体进行获取,代码执行到第15行goto语句进而继续到第23行return指令,方法成功执行退出。

针对异常情况,会执行到第20行指令通过monitorexit释放monitor锁,进一步通过第22行字节码athrow抛出对应的异常。从字节码的指令分析也可以看出在使用synchronized是具备隐式加锁和释放锁便利性的,并且针对异常情况也做出了释放锁的操作

每个对象都存在一个与之关联的monitor,线程对monitor持有的方式以及持有时机决定了synchronized的锁状态以及synchronized的状态升级方式。monitor是通过C++中ObjectMonitor实现,代码可以通过openjdk hotspot链接(hg.openjdk.java.net/jdk8u/jdk8u… )进行下载openjdk中hotspot版本的源码,具体文件路径在src\share\vm\runtime\objectMonitor.hpp,具体源码为:

  // initialize the monitor, exception the semaphore, all other fields// are simple integers or pointers
​
  ObjectMonitor() {
​
•    _header       = NULL;
​
•    _count        = 0;
​
•    _waiters      = 0,
​
•    _recursions   = 0;
​
•    _object       = NULL;
​
•    _owner        = NULL;
​
•    **_WaitSet**      = NULL;
​
•    _WaitSetLock  = 0 ;
​
•    _Responsible  = NULL ;
​
•    _succ         = NULL ;
​
•    _cxq          = NULL ;
​
•    FreeNext      = NULL ;
​
•    **_EntryList**    = NULL ;
​
•    _SpinFreq     = 0 ;
​
•    _SpinClock    = 0 ;
​
•    OwnerIsThread = 0 ;
​
•    _previous_owner_tid = 0;
​
  }

ObjectMonitor结构中可以看出主要维护WaitSet以及EntryList两个队列保存ObjectWaiter对象,当每个阻塞等待获取锁的线程都会被封装成ObjectWaiter对象来进行入队,与此同时如果获取到锁资源的话就会出队操作。另外_owner指向当前持有ObjectMonitor对象的线程。等待获取锁以及锁出队的示意图如下:

image.png

  1. 当多个线程进行获取锁的时候,首先都会进入_EntryList队列
  2. 其中一个线程获取到对象的monitor后,就会将_owner变量设置为当前线程,并且monitor维护的计数器加1.
  3. 如果当前线程执行完逻辑并退出后,monitor中_owner变量就会清空并计数器减一,这样其他线程就能竞争到monitor。
  4. 另外,如果调用了wait()方法后,当前线程就会进入到_WaitSet中等待被唤醒,执行退出后,也会对状态量进行重置

从线程状态变化角度来看,如果要想进入到同步块或者执行同步方法,都需要先获取到对象的monitor,如果获取不到则会变更为BLOCKED状态,具体过程如下图:

image.png 从上图可以看出任意线程对Object的访问,首先要获取Object的monitor,如果获取失败,该线程进行同步队列中,线程状态变为BLOCKED。当monitor持有者释放后,在同步队列中的线程才会有机会重新获取monitor,才能继续执行。

2.2 synchronized的happens-before的关系

前文提到happens-before规则,其中有一条就是监视器规则:对同一个监视器的解锁happens-before于对该监视器的加锁。示例代码如下:

public class MonitorDemo {
    private int a = 0;

    public synchronized void writer() {     // 1
        a++;                                // 2
    }                                       // 3

    public synchronized void reader() {    // 4
        int i = a;                         // 5
    }                                      // 6
}

在并发时,第5步操作中读取到的变量a的值是多少呢?这就需要happens-before规则分析。

image.png 图中每个箭头连接的两个节点代表happens-before关系,黑色的是通过程序顺序规则推导。通过监视器锁规则可以推导出线程A释放锁happens-before线程B加锁,即红色线表示。蓝色线则是通过传递性规则进一步推导的happens-before关系,最终得到的结论就是操作2 happens-before 2,通过这个关系可以得出什么?

根据happens-before定义中的一条:如果A happens-before B,则A的执行结果对B可见,那么在该示例代码中,线程A先对共享变量A +1操作,由2 happens-before 5关系可知线程A的执行结果对线程B可见。

三、CAS操作

3.1 什么是CAS?

概念:CAS操作又称为无锁操作,是一种乐观锁策略,它假设所有线程访问共享资源时不会出现锁冲突,那自然就会有线程阻塞的情况发生。那么万一冲突了怎么办?出现冲突通过CAS(compare and swap)操作,通过比较交换鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。

3.2 CAS操作过程

过程:CAS操作过程通过V,O,N三个值达到更新的目的。

  • V:内存地址实际存放值
  • O:预期的值(旧值)
  • N:更新的值

当V和O相同时是表明该值没有被其它线程更改过,那就可以将最新值N赋值给V。反之如果V和O不同,表明该值已经被其它线程修改,则不能将最新值N赋值给V。当多个线程使用CAS操作同一共享变量,只有一个线程能更新成功,其余的会失败,失败的线程会重新尝试。

3.3 CAS问题

  1. ABA问题:因为CAS操作时,通过检查旧值有没有变化决定是否更新。如果一个旧值A先变为B再变为A,CAS检查发现旧值没有变化依然为A,但是实际上的确发生了变化。解决方案可以参考数据库中添加版本号的方式解决这一问题。在java 1.5后atomic包中提供了AtomicStampedReference解决ABA问题。
  2. 自旋时间过长:CAS操作虽然是非阻塞同步,也就是说线程不会挂起,会自旋(循环不断地尝试),如果自旋过长对想能也是很大的消耗。
  3. 只能保证一个共享变量的原子操作:多个线程对一个共享变量的操作使用CAS能够保证其原子性。但是如果多个变量操作,CAS就不能保证其原子性,有一个解决方案是利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量。然后将这个对象做CAS操作就可以保证原子性。

四、锁状态

4.1 对象头

前文中提到同步时获取对象monitor,那么对象的锁是什么呢?我们可以理解为对象的一个标志,该标志存放在java对象的对象头。Java对象头里的Mark Work里默认存放的对象的HashCode,分代年龄和锁标记位。

MarkWord图例: image.png

对象头图例: image.png

4.2 偏向锁

HotSpot作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获取,为了让线程获得锁的代价更低引入了偏向锁。

偏向锁获取

当锁对象第一次被某个线程访问时,会在其对象头的markOop中记录该线程ID,那么下次该线程在进入和退出同步块时就不需要进行CAS操作来加锁和解锁了。只要简单测试下对象头的Mark Word里是否存储指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁,如果测试失败,需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,尝试使用CAS将对象头的偏向锁指向当前线程。 过程:

  • load-and-test简单判断下当前线程id是否与MarkWord中线程id是否一致
  • 如果一致,说明此线程已成功获得了锁,继续执行临界区代码
  • 如果不一致,检查对象是否还是可偏向,即“是否偏向锁”标志位的值
  • 如果还未偏向,则利用CAS操作竞争锁,也即是第一次获取锁时的操作。
  • 如果此对象已经偏向了,并且偏向不是自己,则说明存在了竞争。此时可能就要根据另外线程的情况,可能重新偏向或者偏向撤销或者膨胀升级成轻量级锁了

偏向锁撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

image.png 如图偏向锁的撤销,需要等待全局安全点(在该时间点没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果现场恒仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的MarkWord要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停线程。

下图线程1展示了偏向锁的获取过程,线程2展示了偏向锁撤销的过程

image.png

4.3 轻量级锁

加锁

  • 线程在自己的栈帧中创建用于存储锁记录的空间LockRecord
  • 将锁对象的对象头中的MarkWord复制到线程创建的锁记录空间中
  • 将锁记录中的Owner指针指向锁对象
  • 将锁对象的对象头的MarkWord替换为指向锁记录的指针。

解锁

轻量级解锁时,使用原子的CAS操作将DIsplaced Mark Word替换回对象头,如果成功,表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下图是两个线程同时争夺锁,导致锁膨胀的流程图。

image.png

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞了),一旦锁升级成重量级锁,就不会在恢复到轻量级锁状态。当锁处于这个状态下,其它线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

4.4 锁比较

image.png

image.png

五、原理

5.1 可见性

先说结论,synchronized能避免内存可见性问题,底层也是通过内存屏障指令实现。

前文提及synchronized通过monitorenter指令进行加锁操作,monitorenter指令其实还有Load屏障的作用,monitorexit也有Store屏障的作用。也就是说synchronized代码块内的共享变量如果有变更,数据会立即刷新到主内存,其它线程持有该共享变量缓存失效需要从主内存中重新获取。

5.2 有序性

monitorenter和monitorexit只会令也具有内存屏障(StoreStore/LoadLoad/LoadStore/StoreLoad)的作用,通过内存屏障指令禁止了指令重排序功能,保证了有序性。

image.png

5.3 原子性

通过monitorenter和monitorexit实现,保证同一时间只有一个线程去运行synchronized代码块里的代码。

参考: