您的位置:首页 > 其它

四、聊聊并发 - 看完你应该就明白synchronized是怎么回事了

2020-05-11 04:13 1846 查看

文章目录

  • 三、synchronized的实现原理
  • synchronized的实现
  • 总结

  • 对于Java开发者来说synchronized关键字肯定不陌生,对它的用法我们可能已经能信手扭来了,但是我们真的对它深入了解吗?虽然网上有很多文章都已经将synchronized关键字的用法和原理讲明白了,但是我还是想根据我个人的认识,来跟大家伙来聊一聊这个关键字。我不想上来就搞什么实现原理,我们来一起看看synchronized的用法,再由浅到深的聊聊synchronized的实现原理,从而彻底来彻底掌握它。

    一、前言

    我们都知道synchronized关键字是Java语言级别提供的锁,它可以为代码提供有序性和可见性的保。synchronized作为一个互斥锁,一次只能有一个线程在访问,我们也可以把synchronized修饰的区域看作是一个临界区,临界区内只能有一个线程在访问,当访问线程退出临界区,另一个线程才能访问临界区资源。

    二、synchronized关键字的用法

    1. 怎么用

    synchronized一般有两种用法:synchronized 修饰方法和 synchronized 代码块。

    我们就通过下面的例子,一起感受一下synchronized的使用,感受一下synchronized这个锁到底锁的是什么。

    public class TestSynchronized {
    private final Object object = new Object();
    
    //修饰静态方法
    public synchronized static void methodA() {
    System.out.println("methodA.....");
    try {
    Thread.sleep(1000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }
    
    //代码块synchronized(object)
    public void methodB() {
    synchronized (this) {
    System.out.println("methodB.....");
    try {
    Thread.sleep(1000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }
    }
    
    //代码块synchronized(class)
    public void methodC() {
    synchronized (TestSynchronized.class) {
    System.out.println("methodC.....");
    try {
    Thread.sleep(1000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }
    }
    
    //修饰普通法法
    public synchronized void methodD() {
    System.out.println("methodD.....");
    try {
    Thread.sleep(1000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }
    //修饰普通的object
    public void methodE() {
    synchronized (object) {
    System.out.println("methodE.....");
    try {
    Thread.sleep(1000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }
    }
    }

    我们上面的例子基本上包含了synchronized的所有使用方法,我们通过运行这个例子,看一下方法的打印顺序是怎么样的。

    1. 首先我们调用同一个对象的 methodB和methodD 方法,来对比下 synchronized (this)和 synchronized method(){} 这两种方式。
    final TestSynchronized obj = new TestSynchronized();
    new Thread(() -> {
    obj.methodB();
    }).start();
    
    new Thread(() -> {
    //obj.methodB();
    obj.methodD();
    }).start();

    不管是两个线程调用同一个方法,还是不同的方法,我们通过运行代码可以看到,控制台都是先打印methodB…等了一秒钟才打印出另一个线程调用的方法输出结果。

    为什么会先打印了methodB过一会才打印methodD呢?先看下图,我们就以调用不同方法为例。

    我们文章刚开始也介绍了synchronized的作用其实相当于一把锁,其实我们也可以看做是一个临界区,通过代码的运行结果,我们看到这里先打印了methodB…,过了一会才打印了methodD方法。我们可以感觉到这两个线程访问的好像是同一块临界区,不然的话,控制台应该几乎同时打印出来methodB…和methodD…,这个的话我们也可以自己运行上面的例子,来看一下打印的先后顺序。

    我们也可以通过代码来分析一下,this指的是什么呢?this指的是调用这个方法的对象,那调用synchronized method()的又是被实例化出来的对象,所以当在同一个实例对象调用synchronized method()和synchronized(this)的时候,使用的是一个临界区,也就是我们所说的使用的同一个锁。

    这里的话我个人觉得临界区的这个概念应该会比较好理解一点。我们可以把synchronized method()和 synchronized()修饰的代码都当做一个临界区,如果调用synchronized修饰的方法对象和synchronized代码块里面传入的参数是同一个对象(这里我们说的是同一个对象是指他们的hashCode是相等的),则表明使用的是同一个临界区,否则就不是。

    1. 那我们来继续看看下面的这个
    final TestSynchronized obj = new TestSynchronized();
    final TestSynchronized obj1= new TestSynchronized();
    new Thread(() -> {
    //obj.methodC();
    obj1.methodC();
    }).start();
    
    new Thread(() -> {
    //obj.methodC();
    TestSynchronized.methodA();
    }).start();

    不管我们使用obj1 还是 obj 调用methodC方法,或者是obj调用methodC()和obj1调用methodC()方法,打印的顺序都是先打印methodC…过一秒才打印出来另外的一个输出。

    那这里的话其实和上面使用object对象类似,只不过这里换成了Class对象。代码在运行时,只会生成一个Class对象。我们知道static修饰方法时,那方法就属于类方法,所以这里的话synchronized(Object.Class)和 statci synchronized method()都是使用的Object.class作为锁。

    这里我们就不一一去举例说明了,可能大家伙也能知道我想传达的意思,有兴趣的小伙伴可以自己动手跑一跑代码,看一下结果。在这里的话我就直接给出了结论了。

    1. 当一个线程访问同一个object对象中的synchronized(this)代码块或synchronized method()方法时,或者一个线程访问object的synchronized(this)同步代码块,另外线程访问synchronized method()时都会被阻塞。一次只能有一个线程得到执行。另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。

      当一个线程访问一个对象的synchronized(object)代码块或synchronized method()时,其他线程可以同时访问这个对象的非synchronized(obj)或synchronized method()方法。 这里需要注意的是对于***同一个对象***

    2. 当一个线程访问static synchronized method()修饰的静态方法或synchronized()代码块,里面的参数是一个Classs时,另外一个线程访问 synchronized(Object.class)或 访问 static synchronized method()都会被阻塞。

      当一个线程访问synchronized修饰的静态方法是,其他线程可以同时访问其他synchronized 修饰的非静态方法,或者者是非synchronized(Object.class)。注意的是这里的class必须是同一个class

      这里要说明一下其实 Object.class == Object.getClass(); .class 代表了一个Class的对象。这里的Class不是Java中的关键字,而是一个类。

    2. 可以解决什么问题

    我们上面已经了解synchronized的一些用法,我们前面其实也介绍过synchronized可以解决多线程并发访问共享变量时带来可见性、原子性问题。除此之外呢,其实还可以利用synchronized和wait()/notify()来实现线程的交替顺序执行。我们就通过下面的例子或者图片看一下。

    下面一个例子是经典的经典的打印ABC的问题

    public class TestABC implements Runnable{
    private String name;
    private Object pre;
    private Object self;
    
    public TestABC(String name,Object pre,Object self){
    this.name = name;
    this.pre = pre;
    this.self = self;
    }
    
    @Override
    public void run(){
    
    int count = 10;
    while(count>0){
    synchronized (pre) {
    synchronized (self) {
    System.out.print(name);
    count --;
    //释放锁,开启下一次的条件
    self.notify();
    }
    try {
    //给之前的数据加锁
    pre.wait();
    } catch (Exception e) {
    // TODO: handle exception
    }
    
    }
    
    }
    
    }
    
    public static void main(String[] args) throws InterruptedException {
    Object a = new Object();
    Object b = new Object();
    Object c = new Object();
    
    Thread pa = new Thread(new TestABC("A",c,a));
    Thread pb = new Thread(new TestABC("B",a,b));
    Thread pc = new Thread(new TestABC("C",b,c));
    pa.start();
    TimeUnit.MILLISECONDS.sleep(100);
    pb.start();
    TimeUnit.MILLISECONDS.sleep(100);
    pc.start();
    }
    }

    可能有人一开始理解不了,这是什么鬼代码,其实我刚开始学习这个synchronized关键字时,也没有很好地能理解这个快代码,那就来一起分析看一下。

    上图是我利用一个桌面程序跑出来的效果,但是它这边只能是在同一个锁上进行的,没有办法模拟多个锁,但是我们可以看到wait()/notify()带来的效果。当正在执行的线程调用wait()的时候,线程会主动让出来锁的归属权,我们也可以理解为离开了临界区,那其他线程就可以进入到这个临界区。调用wait()的线程,只能通过被调用notify()才能唤醒,唤醒之后又可以重新去或者取临界区的执行权。

    那通过上图就更好解释示例代码是如何运行的了。

    我们启动线程的时候是按照A、B、C这样的先后顺序来启动的。当A线程执行完以后,这里会在c临界区等待被唤醒,也就是左上角的步骤3,同样线程B执行完以后会在a临界区等待被唤醒,同样线程C会在b临界区等待被唤醒。

    当线程按照这个顺序启动完成以后,之后的线程调度就交由CPU去进行执行顺序是不确定的,但是当线程C执行完以后,会唤醒在c临界区等待的线程A,而线程B会一直被阻塞,直到在a临界区上等待的线程被唤醒(也就是执行a.notify()),才能重新执行。同理,其他两个线程也是如此,这样就完成了线程的顺序执行。

    三、synchronized的实现原理

    通过上述所说,我们可能大概也许对synchronized有那么一点感觉了。其实synchronized就是一个锁(也可以理解为临界区),那synchronized到底是如何实现的呢?synchronized到底锁住的是什么呢?这是我们接下来要说的主要内容

    我们在说内存模型的时候,提到了Java内存模型中提供了8个原子操作,其中有两个操作是lock和unlock,这两个原子操作在Java中使用了两个更高级的指令moniterenter和moniterexit来实现的,synchronized实现线程的互斥就是通过这两个指令实现的,但是synchronized 修饰方法 以及synchronized代码块实现还有稍微的有一些区别,那我就来看看这两个实现的区别。

    同步方法

    我们通过javap命令对我们的Java代码进行反编译一下,我们可以看到如下图的字节码

    我们通过反编译以后的字节码没有发现任何和锁有关的线索。不要着急,我们通过javap -v 命令来反编译看一下


    我们发现methodD()方法中有一个flags属性,里面有一个ACC_SYNCHRONIZED,这个看起来好像和synchronized有些关系。

    通过查资料发现JVM规范对于synchronized同步方法的一些说明:资料1资料2

    其大致意思可以概括为以下几点

    • 同步方法的实现不是基于monitorenter和monitorexit指令来实现的
    • 同步方法在运行时,常量池里通过ACC_SYNCHRONIZED来区分是否是同步方法,方法执行时会检查该标志
    • 当一个方法有这个标志的时候,进入的线程首先需要获得监视器才能执行该方法

    这里给出了method_info的一些详细说明,可以参官方文档。

    同步代码块

    我们通过反编译我们上面的代码,得到methodC的字节码如下。

    这里我们可以看到有两个指令moniterenter和moniterexit,JVM规范对于这两个指令的给出了说明:官方资料

    Monitorenter

    Each object has a monitor associated with it. The thread that executes monitorenter gains ownership of the monitor associated with objectref. If another thread already owns the monitor associated with objectref, the current thread waits until the object is unlocked,

    每个对象都有一个监视器(Monitor)与它相关联,执行moniterenter指令的线程将获得与objectref关联的监视器的所有权,如果另一个线程已经拥有与objectref关联的监视器,则当前线程将等待直到对象被解锁为止。

    Monitorexit

    The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.

    当执行monitorexit的时候,和该线程关联的监视器的计数就减1,如果计数为0则退出监视器,该线程则不再是监视器的所有者。

    synchronized的实现

    JVM规范中也说到每一个对象都有一个与之关联的Monitor,接下来我们来看看到底他们之间有什么关联。

    对象内存结构

    HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

    HotSpot虚拟机对象的对象头部分包括两类信息。第一类是用于存储对象自身的运行时数据,如哈 希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。

    我们所说的锁标识就存储在Mark Word中,其结构如下。

    其中标志位10对应的指针,就是指向Monitor对象的,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 ;
    }

    ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示

    由此看来,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式互斥的。

    总结

    上述我们分析了synchronized的基本使用,以及synchronized的实现原理,我们对synchronized的这个关键字应该有了大致的掌握,上面说的那个小程序,如果有朋友感兴趣可以给我留言,我可以分享给大伙,大家也自己操作感受一下。希望看完有收获的小伙伴点个赞。

    参考:

    《深入理解Java虚拟机》

    https://www.jianshu.com/p/7f8a873d479c

    https://blog.csdn.net/jinjiniao1/article/details/91546512

    lisnail1 原创文章 7获赞 8访问量 1013 关注 私信
    内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
    标签: