Synchronized与Java线程的关系

前言

​ Java多线程处理任务时,为了线程安全,通常会对共享资源进行加锁,拿到锁的线程才能进行访问共享资源。而加锁方式通过都是Synchronized锁或者Lock锁。

​ 那么多线程在协同工作的时候,线程状态的变化都与锁对象有关系。

Synchronized锁

​ Java采用synchronized关键字、以互斥同步的方式的解决线程安全问题。一般Synchronized主要用于同步代码块、实例方法、静态方法。

一、使用方式 字节码分析

// 使用
public synchronized void test1(){
}
public void test2(){
    synchronized(new Test()){
    }
}
public static synchronized void test3(){
}

//反编译
public synchronized void test1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED    // here

  public void test2();
    descriptor: ()
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: new           #2                  // class com/easy/helloworld/Test
         3: dup
         4: invokespecial #3                  // Method "":()V
         7: dup
         8: astore_1
         9: monitorenter                   // here
        10: aload_1
        11: monitorexit                    // here
        12: goto          20
        15: astore_2
        16: aload_1
        17: monitorexit                    // here
        18: aload_2
        19: athrow
        20: return

  public static synchronized void test3();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED   // here

​ 由上可知,同步代码:通过moniterenter、moniterexit 关联到到一个monitor对象,进入时设置Owner为当前线程,计数+1、退出-1。除了正常出口的 monitorexit,还在异常处理代码里插入了 monitorexit。

​ 而实例方法、静态方式是隐式调用moniterenter、moniterexit。

二、Moniterenter、Moniterexit

​ monitorenter和monitorexit这两个jvm指令,主要是基于 Mark WordObject monitor来实现的。

​ 在 JVM 中,对象在内存中分为三块区域:

  • 对象头:由Mark WordKlass Point构成。

    • Mark Word(标记字段):用于存储对象自身的运行时数据,例如存储对象的HashCode,分代年龄、锁标志位等信息,是synchronized实现轻量级锁和偏向锁的关键。 64位JVM的Mark Word组成如下:

      Synchronized与Java线程的关系_第1张图片

    • Klass Point(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

  • 实例数据:这部分主要是存放类的数据信息,父类的信息。

  • 字节对齐:为了内存的IO性能,JVM要求对象起始地址必须是8字节的整数倍。对于不对齐的对象,需要填充数据进行对齐。

​ 在JDK 1.6之前,synchronized只有传统的锁机制,直接关联到monitor对象,存在性能上的瓶颈。在JDK 1.6后,为了提高锁的获取与释放效率,JVM引入了两种锁机制:偏向锁和轻量级锁。它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。这几种锁的实现和转换正是依靠对象头中的Mark Word

Synchronized锁机制

1、偏向锁

1)引入偏向锁的初衷

​ 在没有多线程竞争的情况下,尽量减少不必要的轻量级锁的执行。轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只依赖一次CAS原子指令。

​ 但在多线程竞争时,需要进行偏向锁撤销步骤,因此其撤销的开销必须小于节省下来的CAS开销,否则偏向锁并不能带来收益。JDK 1.6中默认开启偏向锁,可以通过-XX:-UseBiasedLocking来禁用偏向锁。在JDK15中,已经默认禁用偏向锁了。

2)关键字

prototype_header:JVM中的每个类有一个类似mark wordprototype_header用来标记该class的epoch和偏向开关等信息

匿名偏向状态:锁对象mark word标志位为101,且存储的Thread ID为空时的状态(即锁对象为偏向锁,且没有线程偏向于这个锁对象)。

Atomic::cmpxchg_ptrCAS函数。这个方法有三个参数,依次为exchange_valuedestcompare_value。如果dest的值为compare_value则更新为exchange_value,并返回compare_value。否则,不更新并返回实际原值

3)偏向锁流程

步骤 1、从当前线程的栈中找到一个空闲的Lock Record,并指向当前锁对象。

步骤 2、获取对象的markOop数据mark,即对象头的Mark Word;

步骤 3、判断锁对象的mark word是否是偏向模式,即低3位是否为101。若不是,进入步骤4。若是,计算anticipated_bias_locking_value,判断偏向状态:

步骤 3.1anticipated_bias_locking_value若为0,代表**偏向的线程是当前线程,且mark word的epoch等于class的epoch,**这种情况下直接执行同步代码块,什么都不用做。

步骤 3.2判断class的prototype_header是否为非偏向模式。若为非偏向模式,CAS尝试将对象恢复为无锁状态。无论cas是否成功都会进入轻量级锁逻辑。

步骤 3.3、**如果epoch偏向时间戳已过期,则需要重偏向。**利用CAS指令将锁对象的mark word替换为一个偏向当前线程且epoch为类的epoch的新的mark word

步骤 3.4CAS将偏向线程改为当前线程后,如果当前是匿名偏向(即对象头中的bit field存储的Thread ID为空)且无并发冲突,则能修改成功获取偏向锁,否则进入锁升级的逻辑。

步骤 4、走到一步会进行轻量级锁逻辑。构造一个无锁状态的mark word,然后存储到Lock Record

设置为无锁状态的原因是:轻量级锁解锁时是将对象头的mark wordcas替换为Lock Record中的Displaced Mark Word,所以设置为无锁状态。如果是锁重入,则将Lock RecordDisplaced Mark Word设置为null,放到栈帧中,起到计数作用。

注意

只有匿名偏向的对象才能进入偏向锁模式。JVM启动时会延时初始化偏向锁,默认是4000ms。初始化后会将所有加载的Klass的prototype header修改为匿名偏向样式。当创建一个对象时,会通过Klass的prototype_header来初始化该对象的对象头。

​ 简单的说,偏向锁初始化结束后,后续所有对象的对象头都为匿名偏向样式,在此之前创建的对象则为无锁状态。而对于无锁状态的锁对象,如果有竞争,会直接进入到轻量级锁。这也是为什么JVM启动前4秒对象会直接进入到轻量级锁的原因。

​ 那么为什么要延迟初始化?JVM启动时必不可免会有大量sync的操作,而偏向锁并不是都有利。如果开启了偏向锁,会发生大量锁撤销和锁升级操作,大大降低JVM启动效率。

4)偏向锁的撤销

偏向锁的 撤销(revoke)是一个很特殊的操作,为了执行撤销操作,需要等待全局安全点,此时所有的工作线程都停止了执行。

偏向锁的撤销操作并不是将对象恢复到无锁可偏向的状态,而是在偏向锁的获取过程中,发现可能存在竞争时,直接将一个被偏向的对象升级到被加了轻量级锁的状态。

场景说明:

​ 锁已经偏向线程A,此时线程B尝试获取锁。这种情况下会走到Mark标记的分支。

​ 如果需要撤销的是当前线程,只要遍历当前线程的栈就能拿到lock record,可以直接调用revoke_bias,不需要等到safe point再撤销。**在调用Object#hashcode时,也会走到该分支将为偏向锁的锁对象直接恢复为无锁状态。**若不是当前线程,会被push到VM Thread中等到safe point的时候再执行。

​ VMThread内部维护了一个VMOperationQueue类型的队列,用于保存内部提交的VM线程操作VM_operation。GC、偏向锁的撤销等操作都是在这里被执行。

撤销流程:

**步骤 1、查看偏向的线程是否存活,如果已经死亡,则直接撤销偏向锁。**JVM维护了一个集合存放所有存活的线程,通过遍历该集合判断某个线程是否存活。

步骤 2、偏向的线程是否还在同步块中,如果不在,则撤销偏向锁。如果在同步块中,执行步骤3。

​ 而是否在同步块的判断依据偏向锁的重入计数方式:在偏向锁的获取中,每次进入同步块的时候都会在栈中找到第一个可用(即栈中最高的)的Lock Record,将其obj字段指向锁对象。每次解锁的时候都会把最低的Lock Record移除掉,所以可以通过遍历线程栈中的Lock Record来判断是否还在同步块中。轻量级锁的重入也是基于Lock Record的计数来判断。

步骤 3、升级为轻量级锁。将偏向线程所有相关Lock RecordDisplaced Mark Word设置为null,再将最高位的Lock RecordDisplaced Mark Word 设置为无锁状态,然后将对象头指向最高位的Lock Record。这里没有用到CAS指令,因为是在safepoint,可以直接升级成轻量级锁。

小结

​ 当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。

​ 因此,JVM中增加了一种批量重偏向/撤销的机制以减少锁撤销的开销,而mark word中的epoch也是在这里被大量应用。但无论怎么优化,偏向锁的撤销仍有一定不可避免的成本。如果业务场景存在大量多线程竞争,那偏向锁的存在不仅不能提高性能,而且会导致性能下降。

总结

如果当前锁已偏向其他线程||epoch值过期||class偏向模式关闭||获取偏向锁的过程中存在并发冲突,都会进入到锁膨胀InterpreterRuntime::monitorenter方法, 在该方法中会进行偏向锁撤销和升级。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2dzNki4f-1692344710003)(https://tech.youzan.com/content/images/2021/07/—.svg)]

2)轻量级锁

目的:

​ 在多线程交替执行同步块的情况下,尽量避免重量级锁使用的操作系统互斥量带来的开销。但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁。

​ 在偏向锁逻辑中,cas失败会执行到InterpreterRuntime::monitorenter。在轻量级锁逻辑中,如果当前线程不是轻量级锁的重入,也会执行到InterpreterRuntime::monitorenter

​ 其内部函数fast_enter的流程,主要逻辑为revoke_and_rebias:如果当前是偏向模式且偏向的线程还在使用锁,会将锁的mark word改为轻量级锁的状态,并将偏向的线程栈中的Lock Record修改为轻量级锁对应的形式(此时Lock Record是无锁状态),且返回值不是BIAS_REVOKED_AND_REBIASED,会继续执行slow_enter

slow_enter流程步骤

步骤 1markOop mark = obj->mark()方法获取对象的markOop数据mark;

步骤 2mark->is_neutral()方法判断mark是否为无锁状态,标识位001

步骤 3、如果mark处于无锁状态,把mark保存到BasicLock对象(Lock Record的属性)的displaced_header字段;

步骤 3.1、通过CAS尝试将Mark Word更新为指向BasicLock对象的指针,如果更新成功,表示竞争到锁,则执行同步代码,否则执行步骤4;

步骤 4、如果是重入,则设置Displaced Mark Word为null。

步骤 5、到这说明有多个线程竞争轻量级锁,轻量级锁需要膨胀升级为重量级锁;

轻量级锁的释放

​ 轻量级锁释放时需要将Displaced Mark Word替换回对象头的mark word中。如果CAS失败或者是重量级锁则进入到InterpreterRuntime::monitorexit方法中。monitorexit直接调用slow_exit方法释放Lock Record

最后执行的是如果是fast_exit方法,且是轻量级锁,尝试cas替换mark word。若解锁时有竞争,会调用inflate方法进行重量级锁膨胀,升级到到重量级锁后再执行exit方法。

3)重量级锁

​ 重量级锁通过对象内部的监视器(monitor)实现,其依赖于底层操作系统的Mutex Lock实现,需要额外的用户态到内核态切换的开销。由上文分析,slow_enter获取轻量级锁未成功时,会在inflate中完成锁膨胀。

​ 而inflate其中是一个for循环,主要是为了处理多线程同时调用inflate的情况。然后会根据锁对象的状态进行不同的处理:

1.已经是重量级状态,说明膨胀已经完成,返回并继续执行ObjectMonitor::enter方法。
2.如果是轻量级锁则需要进行膨胀操作。
3.如果是膨胀中状态,则进行忙等待。
4.如果是无锁状态则需要进行膨胀操作。

步骤过程

步骤 1、调用omAlloc获取一个可用的ObjectMonitor对象。在omAlloc方法中会先从线程私有monitor集合omFreeList中分配对象,如果omFreeList中已经没有monitor对象,则从JVM全局gFreeList中分配一批monitoromFreeList中;

步骤 2、通过CAS尝试将Mark Word设置为markOopDesc:INFLATING,标识当前锁正在膨胀中。如果CAS失败,说明同一时刻其它线程已经将Mark Word设置为markOopDesc:INFLATING,当前线程进行自旋等待膨胀完成。

步骤 3、如果CAS成功,设置monitor的各个字段:设置monitor的header字段为displaced mark word,owner字段为Lock Record,obj字段为锁对象等;

步骤 4、设置锁对象头的mark word为重量级锁状态,指向第一步分配的monitor对象;

monitor竞争

​ 当锁膨胀inflate执行完并返回对应的ObjectMonitor时,并不表示该线程竞争到了锁,真正的锁竞争发生在ObjectMonitor::enter方法中。

monitor等待

ObjectMonitor竞争失败的线程,通过自旋执行ObjectMonitor::EnterI方法等待锁的释放。

EnterI大致原理

一个ObjectMonitor对象包括两个同步队列(_cxq_EntryList) ,以及一个等待队列_WaitSet。cxq、EntryList 、WaitSet都是由ObjectWaiter构成的链表结构。其中,_cxq为单向链表,_EntryList为双向链表。

​ 当一个线程尝试获得重量级锁且没有竞争到时,该线程会被封装成一个ObjectWaiter对象插入到cxq的队列的队首,然后调用park函数挂起当前线程,进入BLOCKED状态。

​ 当线程释放锁时,会根据唤醒策略,从cxq或EntryList中挑选一个线程unpark唤醒。如果线程获得锁后调用Object#wait方法,则会将线程加入到WaitSet中,进入WAITING或TIMED_WAITING状态。

​ 当被Object#notify唤醒后,会将线程从WaitSet移动到cxq或EntryList中去,进入BLOCKED状态。需要注意的是,当调用一个锁对象的waitnotify方法时,若当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁。

monitor释放

​ 当某个持有锁的线程执行完同步代码块时,会进行锁的释放。在HotSpot中,通过退出monitor的方式实现锁的释放,并通知被阻塞的线程,具体实现位于ObjectMonitor::exit方法中。

Java线程

1、线程的实现

1)内核线程实现

内核线程(Kernel-Level Thread,KLT):由内核来完成线程切换,内核通过调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。 程序一般不会直接去使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),也就是通常意义上的线程。

优点:每个LWP都是独立的调度单元。一个LWP被阻塞,不影响其他LWP。

缺点:基于KLT,耗资源。线程的创建、析构、同步都需要进行系统调用,频繁的用户态、内核态切换。

2)用户线程实现(User Thread,UT)

广义:非内核线程,都可认为是用户线程。(包括LWT,虽然LWT的大多操作都要映射到KLT)

狭义:完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。UT也只感知到掌管这些UT的进程P。

优点:用户线程的创建、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。

缺点:线程的创建、销毁、切换和调度都是用户必须考虑到问题。“阻塞如何处理”、“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难。

3)混合模式。即存在用户线程,也存在轻量级进程

​ 用户线程的创建、切换、析构等操作依然廉价,可以支持大规模的用户线程并发,且可以使用内核线程提供的线程调度功能及处理器映射。

​ 线程的实现依赖操作系统支持的线程模型。在主流的操作系统上,hotspot、classic、art等虚拟机默认是 1:1的线程模型。在Solaris平台上,hotspot支持1:1、N:M两种线程模型。

2、线程的转换

​ 线程的状态,指的是Thread 类中threadStatus的值。

​ 创建Thread 类的对象,才能谈其状态。这个时候,线程t就处于新建状态。但他还不是“线程”。**调用start()后,会执行一个native方法创建内核线程。 这时候才有一个真正的线程创建出来,并即刻开始运行。这个内核线程与线程t进行1:1的映射。**这时候t具备运行能力,进入RUNNABLE状态。 RUNNABLE可以细分为READY和RUNNING,两者的区别只是是否等待到了资源并开始运行。

​ 处于RUNNABLE且未运行的线程,会进入一个就绪队列中,等待操作系统的调度。处于就绪队列的线程都在等待资源,这个资源可以是cpu的时间片、也可以是系统的IO。

3、线程相关方法

1)wait

​ 通过object获得objectMonitor,将Thread封装成OjectWaiter对象,然后addWaiter将它插入waitSet中,进入waiting或timed_waiting状态。最后释放锁,并通过底层的park方法挂起线程;

2)notify

​ 通过object获得objectMonitor,调用objectMonitor的notify方法。这个notify最后会走到ObjectMonitor::DequeueWaiter方法,获取waitSet列表中的第一个ObjectWaiter节点。并根据不同的策略,将取出来的ObjectWaiter节点,加入到EntryListcxq中。 notifyAll的实现类似于notify,主要差别在多了个for循环。

notifynotifyAll并不会立即释放所占有的ObjectMonitor对象,其真正释放ObjectMonitor的时间点是在执行monitorexit指令。

一旦释放ObjectMonitor对象了,entryListcxq中的ObjectWaiter节点会依据QMode所配置的策略,通过ExitEpilog方法唤醒取出来的ObjectWaiter节点。被唤醒的线程,继续参与monitor的竞争。若竞争失败,重新进入BLOCKED状态

3)join

join的本质仍然是 wait() 方法。在使用join时,JVM会帮我们隐式调用notify,因此我们不需要主动notify唤醒主线程。 而sleep()方法最终是调用SleepEvent对象的park方法

4)sleep

Thread.sleep在jvm层面上是调用thread中SleepEvent对象的park()方法实现阻塞线程,在此过程中会通过判断时间戳来决定线程的睡眠时间是否达到了指定的毫秒。

5)park

parkunpark方法也与同步语义无关。每个线程都与一个许可(permit)关联。unpark函数为线程提供permit,线程调用park函数则等待并消耗permit。

小结

​ 1、 JVM线程状态不代表内核线程状态。

​ 2、BLOCKED的线程一定处于entryList或cxq中,而处于WAITING和TIMED WAITING的线程,可能是由于执行了sleep或park进入该状态,不一定在waitSet中。也就是说,处于BLOCKED状态的线程一定是与同步相关。由这可延伸出,调用 jdk 的 lock并获取不到锁的线程,进入的是 WAITING 或 TIMED_WAITING 状态,而不是BLOCKED状态。

总结

Synchronized与Java线程的关系_第2张图片

你可能感兴趣的:(多线程与线程并发,java)