Java并发(synchronized)

1.synchronized

Java中提供了两种实现同步的基础语义:synchronized方法和synchronized块, 我们来看个demo:

public class SyncTest {
    public void syncBlock(){
        synchronized (this){
            System.out.println("hello block");
        }
    }
    public synchronized void syncMethod(){
        System.out.println("hello method");
    }
}

当SyncTest.java被编译成class文件的时候,synchronized关键字和synchronized方法的字节码略有不同,我们可以用javap -v 命令查看class文件对应的JVM字节码信息,部分信息如下:

{
  public void syncBlock();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter				 	  // monitorenter指令进入同步块
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String hello block
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit						  // monitorexit指令退出同步块
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit						  // monitorexit指令退出同步块
        20: aload_2
        21: athrow
        22: return
      Exception table:
         from    to  target type
             4    14    17   any
            17    20    17   any
 

  public synchronized void syncMethod();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED      //添加了ACC_SYNCHRONIZED标记
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #5                  // String hello method
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
 
}

从上面的中文注释处可以看到,对于synchronized关键字而言,javac在编译时,会生成对应的monitorenter和monitorexit指令分别对应synchronized同步块的进入和退出,有两个monitorexit指令的原因是:为了保证抛异常的情况下也能释放锁,所以javac为同步代码块添加了一个隐式的try-finally,在finally中会调用monitorexit命令释放锁。而对于synchronized方法而言,javac为其生成了一个ACC_SYNCHRONIZED关键字,在JVM进行方法调用时,发现调用的方法被ACC_SYNCHRONIZED修饰,则会先尝试获得锁。

1.1 对象头

因为在Java中,任何对象都可以用作锁,所以最好的办法是将存储对象及其对应锁的信息存储在对象头中。因为对象头本身也有一些hashCode,GC相关的数据。

对象头:

  • 32位虚拟机对象头大小= Mark Word(4B)+ kclass(4B) = 8B
  • 64位虚拟机对象头大小= Mark Word(8B)+ kclass(4B) = 12B
  • 注:在 32 位虚拟机中,1 个机器码等于 4 字节,也就是 32 bits)
    在这里插入图片描述

在JVM 中,对象在内存中除了本身的数据外还有个对象头,对于普通对象而言,其对象头中有两类信息:mark word和类型指针,另外对于数组而言还会有一份记录数组长度的数据。

  • Mark Word(1 个机器码) 用于存储对象自身的运行时数据,它是实现重量级锁、轻量级锁和偏向锁的关键。
  • Klass Point(1 个机器码) 是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  • 对象是数组类型,则还需要1 个机器码,因为 JVM 虚拟机可以通过 Java 对象的元数据信息确定 Java 对象的大小,无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word 会随着程序的运行发生变化,变化状态如下:

  • 32 位虚拟机:
    在这里插入图片描述

    • 每一行,是一种情况。为什么要有 1 bit 的是否是偏向锁,是因为有 5 种状态,偏向锁和无锁状态的锁标志位都是 01 ,所以用 1 bit 的是否偏向锁来区分
    • 无锁状态 和 偏向锁状态不能共存,即如果开启了”偏向锁模式“,那么对象在初始化的时候,其对象头的 MarkWord 部分就是偏向锁状态,而不是无锁状态,其中 thread id = 0。所以说就没法保存 hashcode,所以:
      • 当一个对象已经计算过 identity hash code,它就无法进入偏向锁状态;
      • 当一个对象当前正处于偏向锁状态,并且需要计算其identity hash code的话,则它的偏向锁会被撤销,并且锁会膨胀为轻量级锁或者重量锁;
  • 64 位虚拟机:在这里插入图片描述

2.锁的分类

Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式。会按偏向锁->轻量级锁->重量级锁 的顺序升级,并且升级之后基本不会降级。

  • 偏向锁:只被一个线程持有。对于轻量级锁每次加锁解锁通常都有一个CAS操作;对于重量级锁而言,加锁也会有一个或多个CAS操作,所以为提高一个对象在一段很长的时间内都只被一个线程用做锁对象场景下的性能,引入了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只会执行几个简单的比较和设置,而不是开销相对较大的CAS命令。
  • 轻量锁:不同线程交替持有锁(即 有不是特别多个线程,但同步代码块执行时间短,或者一次持有锁的时间短)
  • 重量锁:多线程竞争锁

补充说明

  • Lock Record 为线程私有,存在于当前线程栈中,是一个线程栈帧;其主要属性有两个 obj (指向锁的指针)、header(即锁的对象头 mark word 部分)
    • 锁记录只真正作用于轻量级锁中,因为只有轻量锁时 mark word 指向 Lock Record
    • 在偏向锁和重量级锁中只是起过渡作用,只是一个锁记录
  • 锁撤销指锁升级、锁释放指当前线程退出同步代码块。
  • 对象头初始化时,其 mark word 要么是 偏向锁的匿名偏向状态,要么是无锁状态(如果是无锁状态说明禁用了偏向锁,那么一开始获取的锁是轻量锁)。
  • 一旦升级为重量级锁,那么其 mark word 指向 objectmonitor 将不再改变,改变的是其指向的 objectmonitor 的 owner 属性。

2.1 CAS

CAS : compare and swap的缩写,中文翻译成比较并交换。CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该 位置的值。

 /**
  * 举例:
  *  var 1: 被 cas 对象
  *  var 2: 要被 cas 的属性,在 var 1 的字段偏移,该入参是通过 Unsafe#objectFieldOffset 获得
  *  var 3: 预期值,该入参通过 Unsafe#get*Volatile 获得
  *  var 5: 新值
  */
 public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5)

CAS 可以保证一次的读-改-写操作是原子操作,在单处理器上该操作容易实现,但是在多处理器上实现就有点儿复杂了。CPU 提供了两种方法来实现多处理器的原子操作:总线加锁 或者 缓存加锁。

  • 总线加锁:总线加锁就是就是使用处理器提供的一个 LOCK# 信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。但是这种处理方式把CPU和内存之间的通信锁住了,在锁定期间,其他处理器都不能其他内存地址的数据,开销大。
  • 缓存加锁:利用缓存一致性协议来保证原子性(MESI )。缓存一致性机制可以保证同一个内存区域的数据仅能被一个处理器修改,也就是说当 CPU1 修改缓存行中的 i 时使用缓存锁定,那么 CPU2 就不能同时缓存了 i 的缓存行。

2.2 偏向锁

当JVM启用了偏向锁模式(1.6以上默认开启),当新创建一个对象的时候,如果该对象所属的class没有关闭偏向锁模式,那新创建对象的mark word将是可偏向状态,此时mark word中的thread id为0,表示未偏向任何线程,也叫做匿名偏向(anonymously biased)。

加锁过程:

  • case 1:当该对象第一次被线程获得锁的时候,发现是匿名偏向状态,则会用CAS指令,将mark word中的thread id由0改成当前线程Id。如果成功,则代表获得了偏向锁,继续执行同步块中的代码。否则,将偏向锁撤销,升级为轻量级锁。
  • case 2:当被偏向的线程再次进入同步块时,发现锁对象偏向的就是当前线程,在通过检查后,会往当前线程的栈中添加一条Displaced Mark Word(即 header 属性)为空的Lock Record,然后继续执行同步块的代码,因为操纵的是线程私有的栈,因此不需要用到CAS指令;由此可见偏向锁模式下,当被偏向的线程再次尝试获得锁时,仅仅进行几个简单的操作就可以了,在这种情况下,synchronized关键字带来的性能开销基本可以忽略。
  • case 3.当其他线程进入同步块时,发现已经有偏向的线程了,则会进入到撤销偏向锁的逻辑里,一般来说,会在safepoint中(在这个时间点上没有字节码正在执行)去查看偏向的线程是否还存活。
    • 如果存活且还在同步块中则将锁升级为轻量级锁,原偏向的线程继续拥有锁,当前线程则走入到锁升级的逻辑里
    • 如果偏向的线程已经不存活或者不在同步块中,则将对象头的mark word改为无锁状态(unlocked),之后再升级为轻量级锁(即发生了竞争,不符合偏向锁的情况了,以后都是轻量级锁)。
  • 由此可见,偏向锁升级的时机为:当锁已经发生偏向后,只要有另一个线程尝试获得偏向锁,则该偏向锁就会升级成轻量级锁。当然这个说法不绝对,因为还有批量重偏向这一机制。

释放过程:

  • 当有其他线程尝试获得锁时,是根据遍历偏向线程的lock record来确定该线程是否还在执行同步块中的代码。因此偏向锁的解锁很简单,仅仅将栈中的最近一条lock record的obj字段设置为null。需要注意的是,偏向锁的解锁步骤中并不会修改对象头中的thread id。

2.3 轻量锁

线程在执行同步块之前,JVM会先在当前的线程的栈帧中创建一个Lock Record,其包括一个用于存储对象头中的 mark word(官方称之为Displaced Mark Word)以及一个指向对象的指针。下图右边的部分就是一个Lock Record。
在这里插入图片描述
加锁过程:

  • 1.在线程栈中创建一个Lock Record,将其obj(即上图的Object reference)字段指向锁对象。
  • 2.直接通过 CAS 指令将 Lock Record 的地址存储在对象头的mark word中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。如果失败,进入到步骤3。
  • 3.如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分(Displaced Mark Word)为null,起到了一个重入计数器的作用。然后结束。 否则说明发生了竞争,需要膨胀为重量级锁。

释放过程:

  • 1.遍历线程栈,找到所有obj字段等于当前锁对象的Lock Record。
  • 2.如果Lock Record的Displaced Mark Word为 null,代表这是一次重入,将obj设置为 null 后 continue。
  • 3.如果Lock Record的Displaced Mark Word不为 null,则利用 CAS 指令将对象头的mark word恢复成为Displaced Mark Word。如果成功,则continue,否则膨胀为重量级锁(失败是因为锁已经膨胀,mark word 已被替换成其他标志)。

轻量级锁种类:

  • 自旋锁
    所谓自旋,是指当有另一个线程想获取被其它线程持有的锁的时候,不会进入阻塞状态,而是使用空循环来进行自旋。注意:自旋是会消耗cpu的,所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短很短的时间就能够获得锁了。

    • 自旋锁的一些问题
      • 如果同步代码块执行的很慢,需要消耗大量的时间,那么这个时侯,其他线程在空循环,消耗cpu
      • 本来一个线程把锁释放之后,当前线程是能够获得锁的,但是假如这个时候有好几个线程都在竞争这个锁的话,那么有可能当前线程会获取不到锁,还得原地等待继续空循环消耗cup,甚至有可能一直获取不到锁,此后再升级为重量级锁相比直接就是重量级锁更加浪费低效。
      • 基于这个问题,我们必须给线程空循环设置一个次数,当线程超过了这个次数,我们就认为,继续使用自旋锁就不适合了,此时锁会再次膨胀,升级为重量级锁。默认情况下,自旋的次数为10次,用户可以通过 -XX:PreBlockSpin 来进行更改。
  • 自适应自旋锁
    所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?

    • 线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。
    • 反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

2.3.1 MCS锁、CLH锁

MCS 来自于其发明人名字的首字母: John Mellor-Crummey和Michael Scott。
CLH的发明人是:Craig,Landin and Hagersten。

MCS锁:
MCS Spinlock 是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,直接前驱负责通知其结束自旋,从而极大地减少了不必要的处理器缓存同步的次数,降低了总线和内存的开销。

CLH锁:
CLH锁也是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。

下图是CLH锁和MCS锁队列图示:
在这里插入图片描述
差异:

  • 从代码实现来看,CLH比MCS要简单得多。
  • 从自旋的条件来看,CLH是在前驱节点的属性上自旋,而MCS是在本地属性变量上自旋。
  • 从链表队列来看,CLH的队列是隐式的,CLHNode并不实际持有下一个节点;MCS的队列是物理存在的。
  • CLH锁释放时只需要改变自己的属性,MCS锁释放则需要改变后继节点的属性。

在AQS (AbstractQueuedSynchronizer ,即队列同步器)中,就使用了 CLH 自旋锁

2.4 重量锁

重量级锁是我们常说的传统意义上的锁,其通过对象内部的监视器(Monitor)实现。

  • Monitor 的本质是 ObjectMonitor,其也有自己的队列,最终阻塞调用的还是 t -> park() 函数,但是 park 依赖于底层操作系统的 mutex Lock、condition 信号量、counter计数器(和 LockSupport 的 park / unpark 相同),由于使用 Mutex Lock 和 cond_wait 都需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的。
  • 每一个 JAVA 对象都会与一个监视器 monitor 关联,重量级锁的状态下,对象的mark word为指向一个堆中 monitor 对象的指针。当一个 monitor 被重量锁对象持有后,该对象将处于锁定状态。

一个 monitor 对象包括这么几个关键字段:cxq(下图中的ContentionList),EntryList ,WaitSet,owner。

ObjectMonitor() {
    _header       = NULL; // 对象头的 mark word
    _count        = 0; // 记录个数
    _waiters      = 0; // wait 状态线程个数
    _recursions   = 0; // 重入计数器
    _object       = NULL; // 关联的对象(和 _header 的对象相同)
    _owner        = NULL; // 哪个线程占有该 monitor
    _WaitSet      = NULL; // 处于wait状态的线程节点
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ; // 当前线程释放锁后,下一个执行的线程
    _cxq          = NULL ; // 获取锁失败的线程节点
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 等待被唤醒的线程节点
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
 }

加锁过程:

  • case 1:如果当前是无锁状态,即 owner 为 null ,如果能CAS设置成功,则当前线程直接获得锁

  • case 2:如果是锁重入,_recursions ++ (重入计数加 1)

  • case 3:当前线程是之前持有轻量级锁的线程,重入计数重置为 1,设置owner字段为当前线程(膨胀后 owner 是指向Lock Record的指针)

  • case 4:该锁已经被占用,先自旋尝试获得锁(设置 owner 为当前线程),这样做的目的是为了减少执行操作系统同步操作带来的开销

    • 自旋的过程中获得了锁,则直接返回
    • 否则调用系统同步,如下:
      在这里插入图片描述
    • 将该线程封装成一个ObjectWaiter对象插入到 cxq(单向链表)的队列的队首
    • 调用park函数挂起当前线程。在linux系统上,park函数最终起作用的是gclib库的pthread_cond_wait,JDK的ReentrantLock底层也是用该 park方法挂起线程的(后面 LockSupport 部分讲)。
    • 当被唤醒后再尝试获得锁
  • case 5 :如果线程获得锁后调用Object#wait方法,则会将线程加入到WaitSet中,当被Object#notify唤醒后,会将线程从 WaitSet(单向链表) 移动到cxq或EntryList中去。

    • 需要注意的是,当调用一个锁对象的wait或notify方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁(因为轻量锁和偏向锁没有保存 wait 线程的存储结构)。

**释放过程: **
当线程释放锁时,会从 cxq 或 EntryList 中挑选一个线程唤醒,被选中的线程叫做Heir presumptive即假定继承人(应该是这样翻译),就是图中的Ready Thread,假定继承人被唤醒后会尝试获得锁,但synchronized是非公平的,所以假定继承人不一定能获得锁(这也是它叫"假定"继承人的原因)。

  • 如果_owner不是当前线程
    • 当前线程是之前持有轻量级锁的线程。由轻量级锁膨胀后还没调用过 enter 方法,_owner会是指向Lock Record的指针。则改为指向当前线程,然后继续执行后面代码
    • 否则异常情况,即当前不是持有锁的线程,抛出异常。
  • 如果重入计数器还不为0,则计数器 -1 后返回
  • 设置owner为null,即释放锁,这个时刻其他的线程能获取到锁。这里是一个非公平锁的优化;
  • 如果当前没有等待的线程则直接返回就好了,因为不需要唤醒其他线程。或者如果说succ不为null,代表当前已经有个"醒着的"继承人线程,那当前线程不需要唤醒任何线程;
  • 根据QMode的不同,会执行不同的唤醒策略(QMode默认为0)
    • QMode = 2且 cxq 非空:取 cxq 队列队首的 ObjectWaiter 对象,调用 ExitEpilog方法(该方法会唤醒ObjectWaiter对象的线程)然后立即返回;
    • QMode = 3且 cxq 非空:把 cxq 队列插入到 EntryList的 尾部;
    • QMode = 4且 cxq 非空:把 cxq 队列插入到 EntryList 的头部;
    • QMode = 0:暂时什么都不做,继续往下看;
  • 只有QMode=2的时候会提前返回,等于0、3、4的时候都会继续往下执行:
    • 如果 EntryList 的 首元素非空,就取出来调用 ExitEpilog 方法,该方法会唤醒 ObjectWaiter 对象的线程,然后立即返回;
    • 如果 EntryList 的首元素为空,就将 cxq 的所有元素放入到 EntryList 中,然后再从 EntryList 中取出来队首元素执行 ExitEpilog 方法,然后立即返回;
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值