synchronized全解

143 阅读11分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第5天,点击查看活动详情

概述

当提到并发如何保证线程安全的时候,相信我们首先想到的是synchronized关键字和Lock锁。

那么,今天我们就来聊聊synchronized关键字。

本文主要分为三个部分来介绍synchronized关键字

  • synchronized的使用
  • synchronized实现原理
  • synchronized的优化(锁升级)

synchronized的使用

synchronized主要可以作用在两个地方:修饰方法、修饰代码块。

修饰方法

synchronized修饰方法的时候默认指定的对象锁是this,也就是当前对象。

但修饰方法分为两个情况,修饰普通方法和修饰静态方法的效果是完全不一样的。

修饰普通方法

public synchronized void sleep(){
    System.out.println(Thread.currentThread().getName() + "准备睡觉");
    try {
        Thread.sleep(2000L);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}
注意:修饰的普通方法内部加了```Thread.sleep(2000L)```代码,目的是为了让获取到锁的线程等待2秒钟,
让测试效果更加显而易见

我们知道修饰普通方法的时候,不同对象的调用获取到的是不同的锁,也就是说不同的对象调用同一个方法,并不会线程堵塞,我们写个代码测试一下。

首先创建两个对象,然后用线程池分别执行两个对象的相同方法看看结果。

// 线程池
ExecutorService service = ThreadPoolUtil.newDefaultThreadPool("testFactory", "testThread");

SynchronizedTest test1 = new SynchronizedTest();
SynchronizedTest test2 = new SynchronizedTest();
service.execute(test1::sleep);
service.execute(test2::sleep);

service.shutdown();

结果如下,可以看到两个线程几乎同时打印输出了。这也说明了Synchronized修饰普通方法时,只会锁住当前对象的方法,并不影响其他对象。

2.gif

修饰静态方法

public static synchronized void eat(){
    System.out.println(Thread.currentThread().getName() + "始终都要吃饭");
    try {
        Thread.sleep(2000L);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

静态方法的调用只需要类名直接点方法名就可以了,不需要创建对象再调用方法。

那我们继续写个测试代码看看修饰静态方法和普通方法到底有何不同

// 线程池
ExecutorService service = ThreadPoolUtil.newDefaultThreadPool("testFactory", "testThread");

service.execute(SynchronizedTest::eat);
service.execute(SynchronizedTest::eat);

service.shutdown();

结果如下,首先线程1获取到了锁并执行了相应的代码,等待了2秒,线程2才进行了输出。

3.gif

所以synchronized在修饰静态方法时,实际上指定的是同一把锁,也就是class对象。多线程在调用静态方法的时候,都只会被一个线程获取到锁。

修饰代码块

当然,synchronized不仅能修饰方法,也可以修饰代码块。并且跟修饰方法很类似,分为两种情况,一种指定锁对象为JavaBean,一种指定锁对象为class对象。关于这两种的区别及效果跟修饰方法很类似,这边就不详细聊了,有兴趣可以自己写个demo测试验证下。

锁对象指定为JavaBean

public void run() {
    while (product.getStockNum() > 0){
        // 增加同步代码块,保证同一时刻只有一个线程能够访问
        synchronized (new Product()){
            product.setStockNum(product.getStockNum() - 1);
            System.out.println("线程" + Thread.currentThread().getName() + "抢到一个" + product.getName() + ",库存还剩" + product.getStockNum());
        }
    }
}

锁对象指定为class对象

public void run() {
    while (product.getStockNum() > 0){
        synchronized (Product.class){
            product.setStockNum(product.getStockNum() - 1);
            System.out.println("线程" + Thread.currentThread().getName() + "抢到一个" + product.getName() + ",库存还剩" + product.getStockNum());
        }
    }
}

synchronized的实现原理

反编译class文件

synchronized是通过JVM软件来实现同步锁的,但是修饰方法和代码块的原理略微有些区别。

我们分别反编译一下看看,但在反编译之前先来看下他的命令。

反编译命令:javap -v xxx.class

javap反编译命令详细使用参考下图

image.png

synchronized修饰方法

源代码如下:

public synchronized void sleep(){
    System.out.println(Thread.currentThread().getName() + "准备睡觉");
    try {
        Thread.sleep(2000L);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

反编译后的字节码

image.png

synchronized修饰代码块

源代码如下:

public void sale(){
    synchronized (product){
        product.setStockNum(product.getStockNum() - 1);
    }
}

反编译后的字节码

image.png

修饰代码块的同步是显示的,修饰方法的同步是隐式的。对比两个字节码,可以看到修饰代码块的有两个命令monitorentermonitorexit,而修饰方法就没有,但有个标识ACC_SYNCHRONIZED

显而易见,修饰代码块的时候,JVM通过monitorentermonitorexit两个命令实现的同步功能;

而修饰方法时,JVM实际上先是识别标识符flags中是否加入ACC_SYNCHRONIZED标识,来确定是否实现同步功能。

他们本质上都是通过monitor监视器实现的。

monitor监视器

monitor监视器这部分没有深入研究,无法很好的表述,更没啥发言权。为了保证文章的完整性,这里截取一部分其他博主的章节供大家参考

montor到底是什么呢?我们接下来剥开Synchronized的第三层,monitor是什么? 它可以理解为一种同步工具,或者说是同步机制,它通常被描述成一个对象。操作系统的管程是概念原理,ObjectMonitor是它的原理实现。

操作系统的管程

  • 管程 (英语:Monitors,也称为监视器) 是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。
  • 这些共享资源一般是硬件设备或一群变量。管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。
  • 与那些通过修改数据结构实现互斥访问的并发程序设计相比,管程实现很大程度上简化了程序设计。
  • 管程提供了一种机制,线程可以临时放弃互斥访问,等待某些条件得到满足后,重新获得执行权恢复它的互斥访问。

ObjectMonitor

ObjectMonitor数据结构

在Java虚拟机(HotSpot)中,Monitor(管程)是由ObjectMonitor实现的,其主要数据结构如下:

 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 ;
  }
复制代码

ObjectMonitor关键字

ObjectMonitor中几个关键字段的含义如图所示:

工作机理

Java Monitor 的工作机理如图所示:

  • 想要获取monitor的线程,首先会进入_EntryList队列。
  • 当某个线程获取到对象的monitor后,进入_Owner区域,设置为当前线程,同时计数器_count加1。
  • 如果线程调用了wait()方法,则会进入_WaitSet队列。它会释放monitor锁,即将_owner赋值为null,_count自减1,进入_WaitSet队列阻塞等待。
  • 如果其他线程调用 notify() / notifyAll() ,会唤醒_WaitSet中的某个线程,该线程再次尝试获取monitor锁,成功即进入_Owner区域。
  • 同步方法执行完毕了,线程退出临界区,会将monitor的owner设为null,并释放监视锁。

为了形象生动一点,举个例子:

  synchronized(this){  //进入_EntryList队列
            doSth();
            this.wait();  //进入_WaitSet队列
  }

注:monitor监视器这个章节内容引用自juejin.cn/post/684490…

synchronized的锁优化

之前经常听别人说synchronized实现同步很重,很浪费性能。但现在JDK版本都升级到十几了,笨重的问题早在1.6之后就得到了解决。

之所以说笨重,无非是因为在JDK1.6之前,线程每次获取资源的时候,都先去获取monitor监视器,而这就会涉及到用户态到内核态的切换,比较浪费系统资源。

至于JDK1.6版本是如何优化synchronized的,这就涉及到一套锁升级理论。

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

对象的内存结构

那么这个升级的过程是靠什么实现的呢?我们后面再讲,先来聊聊对象的结构。

对象在内存中分为三个部分

  • 对象头
  • 实例数据
  • 对其填充

对象头

对象头又分为三个部分

  • Mark Word

    标记字,主要用来存储自身运行时的数据

  • class pointer

    class类对象指针,用来存储方法区中字节码对象的地址,JVM通过这个指针确定本对象属于哪个class的实例

  • array length

    这是一个可选字段,只有对象是数组的时候才会存在,用于记录数组的长度。

对象头中其他两个部分很好理解,我们着重说一下Mark Word标记字。

Java内置锁

Mark Word不仅存储这对象的分代年龄、hashCode、还有锁的相关信息,Java的内置锁就是用Mark Word实现的。

目前市面上有两类虚拟机:32位和64位,自然Mark Word标记字也就分为32位和64位,我们分别看下他们的结构

image.png

32位虚拟机Mark Word结构

image.png

64位虚拟机Mark Word结构

可以看到不管是32位还是64位,有八位表示相同的含义。四位表示分代年龄,一位表示偏向锁标志位,还有两位表示锁的级别。

实例数据

实例数据是对象真正存储的有效信息,

对其填充

对其填充没有实际的作用,目的是为了保证Java对象占用的内存字节数的大小为8的倍数。HotSpot中的内存管理要求对象的起始地址必须是八字节的整数倍。

锁升级的过程

锁升级的过程是不可逆的,但是无锁和偏向锁是可以相互转化的)。

无锁

没有线程访问资源的时候,对象自然是无锁的状态。

偏向锁

HotSpot的作者经过大量的研究发现,其实在大多数时候,一个线程多次获取同一个锁,并不存在锁竞争。而每次请求资源时都去竞争锁的话,会造成不必要的资源浪费,所以才设计了偏向锁。

假设有线程A、B、C。

最初的时候,锁对象是无锁的状态。

当只有线程A获取锁对象时,锁对象的对象头,准确的说是Mark Word会记录此时的threadId,并为锁对象置为偏向锁;

此时这个锁对象就是个线程A的偏向锁对象(偏向锁不会主动释放锁)。

当线程A再次获取锁对象时,会比较当前线程id和对象头中记录的线程id是否一致,如果一致就直接获取锁对象;

如果不一致,则会检查锁对象中记录的线程A是否存活,如果已经没有存活了,锁对象的偏向锁标志位重置为无锁状态,其他线程可以获取锁对象并设置成偏向锁;

如果线程A依旧存活,再检查线程A的栈帧信息,是否需要继续持有这个锁对象。如果不需要,则将锁对象重置为无锁状态;如果需要,则需要升级偏向锁为轻量级锁,多线程同事竞争轻量级锁。

轻量级锁(自旋锁)

轻量级锁主要应用在线程执行时间不长的场景中,也就是说线程持有锁的时间不长。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。

获取轻量级锁时,线程A会复制一份锁对象的对象头Mark Word到线程A的栈帧中(名为锁记录),然后把这个锁记录的地址通过Cas不停的尝试替换到锁对象的对象头中,哪个线程CAS成功,哪个线程也就获取到了轻量级锁。

重量级锁

重量级锁是锁升级的最后一步,也是最消耗资源的一步了。

当轻量级锁CAS循环的次数出过一定的数量,造成CPU太多不必要的空循环,此时轻量级锁就会升级成重量级锁;

重量级锁在多线程中都会去竞争monitor监视器对象,哪个线程获取了monitor对象,其他线程就会被全部堵塞(需要从用户态切换成内核态)。

补充

最后锁优化还有到几个概念:锁消除和锁粗化。也一并说了。

锁消除

Java虚拟机在JIT编译时,会通过上下文分析,去除掉不可能存在共享资源的锁。

锁粗化

Java虚拟机在JIT编译时,如果发现前后相邻的synchronized块使用的是同一个对象,就会把这些synchronized块合并成一个较大的代码块,这样就无须在线程执行这些代码时,频繁申请和释放锁了。

参考文章

blog.csdn.net/qq_43783527…

juejin.cn/post/684490…

blog.csdn.net/wanglianglu…

blog.csdn.net/m0_53474063…

blog.csdn.net/qq_42777071…

blog.csdn.net/zzti_erlie/…

文中如有不足之处,欢迎指正!一起交流,一起学习,一起成长 ^v^