4、Synchronized

烟雨 5年前 (2021-06-13) 阅读数 257 #Java并发
文章标签 并发Java
多线程编程中,有可能会出现多个线程同时访问同一个共享、可变资源的情况,这个资源我们称之其为临界资源;这种资源可能是:对象、变量、文件等。
  1. 共享:资源可以由多个线程同时访问。

  2. 可变:资源可以在其生命周期内被修改。

由于线程执行的过程是不可控的,所以需要采用同步机制来协同对对象可变状态的访问!
实际上,所有的并发模式在解决线程安全问题时,采用的方案都是序列化访问临界资源(即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问)
Java 中,提供了两种方式来实现同步互斥访问:synchronized 和 Lock

一、synchronized

synchronized内置锁是一种对象锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。
加锁的方式:
  • 同步实例方法,锁是当前实例对象。

//代码块1(对象)this指的是当前对象
public void accessResources1(){
    synchronized(this){
        try {
            TimeUnit.MINUTES.sleep(2);
            System.out.println(Thread.currentThread().getName()+" is runing");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  • 同步类方法(静态方法),锁是当前类对象。

//修饰静态方法
public synchronized static void accessResources0(){
    try {
        TimeUnit.SECONDS.sleep(2);
        System.out.println(Thread.currentThread().getName()+" is runing");
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}
  • 同步代码块,锁是括号里面的对象。

//代码块1(CLASS类)
public  void accessResources4(){
    synchronized(SynchroDemo01.class){
        //有Class对象的所有的对象都共同使用这一个锁
        try {
            TimeUnit.SECONDS.sleep(2);
            System.out.println(Thread.currentThread().getName()+" is runing");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

1.1、synchronized底层原理

synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实现。
基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低。
JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平。
分析如下实例代码:
public class SynchroDemo01 {
    /**
     * 代码块1(对象)this指的是当前对象
     */
    public  void accessResources1(){
        synchronized(this){
            try {
                //2分钟的超时等待时间
                TimeUnit.MINUTES.sleep(2);
                System.out.println(Thread.currentThread().getName()+" is runing");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        final SynchroDemo01 deno01=new SynchroDemo01();
        for (int i = 0; i < 5; i++) {
            new Thread(deno01::accessResources1).start();
        }
    }
}

1.1.1、线程的堆栈分析

jconsole分析

cmd控制台输入jconsole

image.png

进入线程监控页面,我们代码生成了5个线程Thread-0到Thread-4。线程等待时间设置为2分钟。
1、进来Thread-0线程抢到CPU资源,sleep()方法让线程处于等待状态。

image.png

其他线程状态为:

image.png

2分钟后,打印:Thread-0 is runing,线程Thread-0执行完成,线程生命周期结束。

image.png

2、剩下其余4个线程抢夺CPU资源,过程同上。
执行完成后的结果

image.png

每2分一个线程走完自己的生命周期,后销毁线程。最后执行完成后结束(线程个数归0)。

image.png

1.1.2、jstack分析

jstack + pid即可查看。

image.png

其中4个线程竞争CPU资源失败,进入阻塞状态、Thread-0线程竞争成功,sleep()方法让线程处于等待状态。
下面列出了线程的所有状态:
public enum State {
    //NEW状态表示线程刚刚被定义,还未实际获得资源以启动,也就是还未调用start()方法。
    NEW,
    //RUNNABLE表示线程当前处于运行状态,当然也有可能由于时间片使用完了而等待CPU重新的调度。
    RUNNABLE,
    //BLOCKED表示线程在竞争某个锁失败时被置于阻塞状态。
    BLOCKED,
    //WAITING和TIMED_WAITING表示线程在运行中由于缺少某个条件而不得不被置于条件等待队列,等待需要的条件或资源。
    WAITING,
    TIMED_WAITING,
    //TERMINATED表示线程运行结束,当线程的run方法结束之后,该线程就会是TERMINATED状态。
    TERMINATED;
}

1.1.3、JVM指令反编译字节码分析

image.png

cmd窗口进入编译成class文件的目录,使用Javap -v查看字节码,accessResources1方法完整字节码如下:

public void accessResources1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=4, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter                      // 进入监视器,获取锁
         4: getstatic     #17                 // Field java/util/concurrent/TimeUnit.MINUTES:Ljava/util/concurrent/TimeUnit;
         7: ldc2_w        #3                  // long 2l
        10: invokevirtual #5                  // Method java/util/concurrent/TimeUnit.sleep:(J)V
        13: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
        16: new           #7                  // class java/lang/StringBuilder
        19: dup
        20: invokespecial #8                  // Method java/lang/StringBuilder."<init>":()V
        23: invokestatic  #9                  // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
        26: invokevirtual #10                 // Method java/lang/Thread.getName:()Ljava/lang/String;
        29: invokevirtual #11                 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        32: ldc           #12                 // String  is runing
        34: invokevirtual #11                 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        37: invokevirtual #13                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        40: invokevirtual #14                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        43: goto          51
        46: astore_2
        47: aload_2
        48: invokevirtual #16                 // Method java/lang/InterruptedException.printStackTrace:()V
        51: aload_1
        52: monitorexit                       // 监视器退出,释放锁
        53: goto          61
        56: astore_3
        57: aload_1
        58: monitorexit                       // 异常退出,释放锁
        59: aload_3
        60: athrow
        61: return
需要注意monitorenter和monitorexit字节码指令。
synchronized关键字被编译成字节码后会被翻译成monitorentermonitorexit两条指令分别在同步块逻辑代码的起始位置与结束位置。

image.png

每个同步对象都有一个自己的Monitor(监视器锁),加锁过程如下图所示:

image.png

1.2、Monitor监视器锁

任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态
Synchronized在JVM里的实现都是基于进入和退出Monitor对象来实现方法同步和代码块同步。
虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter指令MonitorExit指令来实现。

MonitorEnter指令

每个对象都是一个监视器锁。当Monitor被占用时就会处于锁定状态,线程执行MonitorEnter指令时尝试获取Monitor的所有权,过程如下:
  1. 如果Monitor的进入数为0,则该线程进入Monitor,然后将进入数设置为1,该线程即为Monitor的所有者。

  2. 如果线程已经占有该Monitor,则重新进入,则进入Monitor的进入数加1(锁重入)。

  3. 如果其他线程已经占用了Monitor,则该线程进入阻塞状态,直到Monitor的进入数为0,再重新尝试获取Monitor的所有权。

MonitorExit指令

执行MonitorExit的线程必须是对象所对应的Monitor的所有者。指令执行时,Monitor的进入数减1,如果减1后进入数为0,那线程退出Monitor,不再是这个Monitor的所有者。其他被这个Monitor阻塞的线程可以尝试去获取这个Monitor的所有权。
注意:如果MonitorExit指令出现了两次,第1次为同步正常退出释放锁,第2次为发生异步退出释放锁

image.png

Synchronized的语义底层是通过一个Monitor对象来完成,其实wait/notify等方法也依赖于Monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因

方法加锁

//修饰静态方法
public synchronized static void accessResources0(){
    try {
        TimeUnit.SECONDS.sleep(2);
        System.out.println(Thread.currentThread().getName()+" is runing");
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

反编译:

image.png

从编译的结果来看,方法的加锁并没有通过指令MonitorEnter和MonitorExit来完成(理论上其实也可以通过这两条指令来
实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的。
当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程将先获取Monitor,获取成功之后才能执行方法体,方法执行完后再释放Monitor。在方法执行期间,其他任何线程都无法再获得同一个Monitor对象

1.3、什么是Monitor

可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象
与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质。
因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。也就是通常说Synchronized的对象锁,MarkWord锁标识位为10,其中指针指向的是Monitor对象的起始地址
在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的):
ObjectMonitor() {
    _header       = NULL;
    _count        = 0; // 记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; // 处于wait状态的线程,会被加入到_WaitSet队列
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 处于等待锁block状态的线程,会被加入到该集合
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }
每个等待锁的线程都会被封装成ObjectWaiter对象,_WaitSet和_EntryList用来保存ObjectWaiter对象列表。
_owner指向持有ObjectMonitor对象的线程(拿到锁的线程)
当多个线程同时访问一段同步代码时,大致如下:
  1. 首先会进入_EntryList集合,当线程获取到对象的Monitor的后,并把Monitor中的_owner变量设置为当前线程,同时Monitor中的计数器_count加1。

  2. 若线程调用 wait() 方法,将释放当前持有的Monitor,_owner变量恢复为null,_count自减1,同时该线程进入WaitSet集合中等待被唤醒。

  3. 若当前线程执行完毕,也将释放Monitor并复位count的值,以便其他线程进入获取Monitor。

Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),Synchronized锁便是通过这种方式获取锁的。也是为什么Java中任意对象可以作为锁的原因,同时notify/notifyAll/wait等方法会使用到Monitor锁对象,所以必须在同步代码块中使用。
监视器Monitor,可以确保监视器上的数据在同一时刻只会有一个线程在访问

三、对象的内存布局

前面知道synchronized加锁加在对象上,对象是如何记录锁状态的呢?
答案是锁状态是被记录在每个对象的对象头(Mark Word)中
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:
  • 对象头(Header):对象头存储一些信息,比如hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象)等。Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit),但是如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

  • 实例数据(Instance Data):存放类的属性数据信息,包括父类的属性信息。

  • 对齐填充(Padding):由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,方便维护。

image.png

3.1、对象头

HotSpot虚拟机的对象头包括两部分信息,第一部分是“Mark Word”,用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,它是实现轻量级锁和偏向锁的关键。这部分数据的长度在32位和64位的虚拟机(暂 不考虑开启压缩指针的场景)中分别为32个和64个Bits。
虚拟机考虑到空间利用效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间
例如在32位的HotSpot虚拟机 中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bit用于存储对象分代年龄,2Bits用于存储锁标志位,1Bit固定为0。

image.png

64位

image.png

以上是Java对象处于5种不同状态时,Mark Word中64个位的表现形式,上面每一行代表对象处于某种状态时的样子。其中各部分的含义如下:

3.1.1、unused

表示未使用的Bits,unused:25意思有25bits未使用。

3.1.2、identity_hashcode(大小31bits)

大小31位,存储对象标识的hashCode,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。
当对象加锁后(偏向、轻量级、重量级),MarkWord的字节没有足够的空间保存hashCode,因此该hashCode值会移动到管程Monitor中。

3.1.3、age(大小4bits)

大小4位,存储Java对象年龄。
在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。
默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。

3.1.4、biased_lock(大小1bits)

大小1位,存储对象是否启用偏向锁标记。
只有0和1的状态:为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。
lock和biased_lock共同表示对象处于什么锁状态(biased_lock:1+lock:01表示锁为偏向锁)。

3.1.5、lock(大小2bits)

大小2位,存储锁状态标记位。
由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。该标记的值不同,整个Mark Word表示的含义不同。biased_lock和lock一起,表达的锁状态含义如下:

image.png

3.1.6、thread(大小54bits)

大小54位,持有偏向锁的线程ID。

3.1.7、epoch(大小2bits)

大小2位,存储偏向锁的时间戳。

3.1.8、ptr_to_lock_record(大小62bits)

大小62位,轻量级锁状态下,指向栈中锁记录的指针。

3.1.8、ptr_to_heavyweight_monitor(大小62bits)

大小62位,重量级锁状态下,指向对象监视器Monitor的指针。
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
现在我们虚拟机基本是64位的,而64位的对象头有点浪费空间,JVM默认会开启指针压缩(-XX:+UseCompressedOops),所以基本上也是按32位的形式记录对象头的。
哪些会被压缩?
  1. 对象的全局静态变量(即类属性)。

  2. 对象头信息:64位平台下,原生对象头大小为16字节,压缩后为12字节。

  3. 对象的引用类型:64位平台下,引用类型本身大小为8字节,压缩后为4字节。

  4. 对象数组类型:64位平台下,数组类型本身大小为24字节,压缩后16字节。

四、锁的膨胀升级过程

锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁
随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级
从JDK 1.6中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。下图为锁的升级全过程:

4f6fdb6e9788ff09f5bf113e2a7871af.png

4.1、偏向锁

偏向锁是JDK 1.6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。
偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请锁时,无需再做任何同步操作,即获取锁的过程这样就省去了大量有关锁申请的操作,从而也就提供程序的性能
对于没有锁竞争的场合,偏向锁有很好的优化效果。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁。
需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
默认开启偏向锁
开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking

4.2、轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word的结构也变为轻量级锁的结构。
轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在多线程同一时间访问同一把锁的场合,就会导致轻量级锁膨胀为重量级锁

4.3、自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。
这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换时间相对比较长时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起。
这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

4.5、锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。
下面代码,o为局部变量,不会被外部引用,所以会出现锁消除。
private void method(){
	Object o = new Object();
    
    synchronized(o){
        System.out.printlen(o);
    }
}
再比如StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。锁消除的依据是逃逸分析的数据支持
锁消除,前提是java必须运行在server模式(server模式会比client模式作更多的优化),同时必须开启逃逸分析。
-XX:+DoEscapeAnalysis // 开启逃逸分析
-XX:+EliminateLocks // 表示开启锁消除。

逃逸分析测试

public class T0_ObjectStackAlloc {
    /**
     * 进行两种测试
     * 关闭逃逸分析,同时调大堆空间,避免堆内GC的发生,如果有GC信息将会被打印出来
     * VM运行参数:-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
     *
     * 开启逃逸分析
     * VM运行参数:-Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
     *
     * 执行main方法后
     * jps 查看进程
     * jmap -histo 进程ID
     */
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 500000; i++) {
            alloc();
        }
        long end = System.currentTimeMillis();
        //查看执行时间
        System.out.println("cost-time " + (end - start) + " ms");
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }

    }

    private static TulingStudent alloc() {
        //Jit对编译时会对代码进行 逃逸分析
        //并不是所有对象存放在堆区,有的一部分存在线程栈空间
        TulingStudent student = new TulingStudent();
        return student;
    }

    static class TulingStudent {
        private long id;
        private int age;
    }
}

jps查看当前运行的代码

image.png

情况1,未开启逃逸分析

image.png

情况2,开启逃逸分析

image.png

使用逃逸分析,编译器可以对代码做如下优化:
  1. 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。

  2. 将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不堆分配。

  3. 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

问题:是不是所有的对象和数组都会在堆内存分配空间?不一定。
版权声明

非特殊说明,本文由Zender原创或收集发布,欢迎转载。

上一篇:3、多线程基础 下一篇:5、AQS

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

作者文章
热门