美文网首页jvmjava 底层
synchronized关键字与Monitor对象研究

synchronized关键字与Monitor对象研究

作者: 得力小泡泡 | 来源:发表于2021-01-05 19:26 被阅读0次

1、关于同步方法的总结

  • 同步方法仍然涉及到同步监视器,只是不需要我们显式的声明。
  • 非静态的同步方法,同步监视器是:this
  • 静态的同步方法,同步监视器是:当前类本身

例子1

class MyClass {
    public synchronized void hello() {
        try {
            Thread.sleep(4000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("hello");
    }

    public synchronized void world() {
        System.out.println("world");
    }
}

class Thread1 extends Thread {

    private MyClass myClass;

    public Thread1(MyClass myClass) {
        this.myClass = myClass;
    }

    @Override
    public void run() {
        myClass.hello();
    }
}

class Thread2 extends Thread {

    private MyClass myClass;

    public Thread2(MyClass myClass) {
        this.myClass = myClass;
    }

    @Override
    public void run() {
        myClass.world();
    }
}

Main方法1

public class MyTest1 {
    public static void main(String[] args) {
        MyClass myClass1 = new MyClass();
        MyClass myClass2 = new MyClass();

        Thread t1 = new Thread1(myClass1);
        Thread t2 = new Thread2(myClass1);

        t1.start();

        try {
            Thread.sleep(700);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        t2.start();
    }
}

输出

hello
world

Main方法2

public class MyTest1 {
    public static void main(String[] args) {
        MyClass myClass1 = new MyClass();
        MyClass myClass2 = new MyClass();

        Thread t1 = new Thread1(myClass1);
        Thread t2 = new Thread2(myClass2);

        t1.start();

        try {
            Thread.sleep(700);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        t2.start();
    }
}

输出

world
hello

结论:非静态的同步方法,同步监视器是:this
当一个类的实例的方法是synchronized修饰时,他们的锁是this,
Main1中,两个线程访问的是同一个实例,是同一把锁
Main2中,两个线程访问的是不同的实例,不是同一把锁

例子2

public class MyClass {
    public synchronized void method1() {}
    
    public synchronized void method2() {}
    
    public synchronized static void method3() {}
    
    public synchronized static void method4() {}
}

MyClass myClass = new MyClass();

结论:
当一个线程正在执行method1方法时,另一个线程可以同时执行method3 或者 method4,因为method1的锁是类的对象的实例,而method3的锁是当前类本身

2、synchronized关键字的字节码

1、当我们使用synchronized关键字来修饰代码块时,字节码层面上是通过monitorenter与monitorexit指令来实现的锁的获取与释放动作

package com.concurrency2;

public class MyTest1 {
    private Object object = new Object();

    public void method()  {
        synchronized (object) {
            wait();
            System.out.println("hello world");
        }
    }
}
public void method() ;
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: getfield      #3                  // Field object:Ljava/lang/Object;
         4: dup
         5: astore_1
         6: monitorenter
         7: aload_0
         8: invokevirtual #4                  // Method java/lang/Object.wait:()V
        11: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
        14: ldc           #6                  // String hello world
        16: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        19: aload_1
        20: monitorexit
        21: goto          29
        24: astore_2
        25: aload_1
        26: monitorexit
        27: aload_2
        28: athrow
        29: return

分析
synchronized关键字都会存在对应的monitorentermonitorexit的字节码指令,最后的那个monitorexit,不管是正常退出还是异常溢出,都会有一个monitorexit让该线程退出

2、当线程进入到monitorenter指令后,线程将会持有Monitor对象,退出monitorexit指令后,线程将会释放Monitor对象

package com.concurrency2;

public class MyTest1 {
    private Object object = new Object();

    public void method() throws InterruptedException {
        synchronized (object) {
            System.out.println("hello world");
            throw new RuntimeException();
        }
    }
}
  public void method() ;
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: getfield      #3                  // Field object:Ljava/lang/Object;
         4: dup
         5: astore_1
         6: monitorenter
         7: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        10: ldc           #5                  // String hello world
        12: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        15: new           #7                  // class java/lang/RuntimeException
        18: dup
        19: invokespecial #8                  // Method java/lang/RuntimeException."<init>":()V
        22: athrow
        23: astore_2
        24: aload_1
        25: monitorexit
        26: aload_2
        27: athrow

为什么这个代码中的字节码却只有一个monitorexit

因为该方法无论如何肯定都是以异常进行同步块退出的,这是唯一的可能,没有其它更多的路径,所以这就是为啥只有一个monitorexit助字符存在的原因。

当修改成这样,就又有两个monitorexit

package com.concurrency2;

public class MyTest1 {
    private Object object = new Object();

    public void method() {
        synchronized (object) {
            System.out.println("hello world");
            int x = 3;
            if(x == 3) {
                throw new RuntimeException();
            }
        }
    }
}

3、对于synchronized关键字修饰方法来说,并没有出现monitorenter和monitorexit指令,而是出现了一个ACC_SYNCHRONIZED标志。原因是JVM使用了ACC_SYNCHRONIZED访问标志来区分一个方法是否为同步方法;当方法被调用时,调用指令会检查该方法是否拥有ACC_SYNCHRONIZEDS标志,如果有,那么执行线程将会先持有方法所在对象的Monitor对象,然后再去执行方法体;在该方法执行期间,其他任何线程均无法再获取到这个Monitor对象,当线程执行完该方法后,它会释放掉这个Monitor对象。

非静态方法

package com.concurrency2;

public class MyTest1 {
    public synchronized void method() {
        System.out.println("hello world");
    }
}
public synchronized void method();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String hello world
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 5: 0
        line 6: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  this   Lcom/concurrency2/MyTest1;

静态方法

package com.concurrency2;

public class MyTest1 {
    public static synchronized void method() {
        System.out.println("hello world");
    }
}
public static synchronized void method();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String hello world
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 5: 0
        line 6: 8

3、自旋对于synchronized关键字的底层意义

JVM中的同步是基于进入与退出监视器对象(管程对象)(Monitor)来实现的,每个对象实例都会有一个Monitor对象,Monitor对象会和Java对象一同创建和销毁。Monitor对象是由C++来实现的

当多个线程同时访问一段同步代码时,这些线程会被放到一个EntryList集合中,处于阻塞状态的线程都会被放到该列表中。接下来,当线程获取到对象的Monitor时,Monitor是依赖于底层操作系统的mutex lock来实现互斥的,线程获取mutex成功,则会持有该mutex,这时其他线程就无法获取到该mutex

如果线程调用了wait方法,那么该线程就会释放掉所持有的mutex,并且该线程会进入到WaitSet集合(等待集合)中,等待下一次被其他线程调用notify/notifyAll 唤醒。如果当前线程顺利执行完毕方法,那么它也会释放掉所持有的mutex。注意:该线程被唤醒后,如果还是没有抢到mutex,则会被放到一个EntryList集合

总结一下:同步锁在这种实现方式当中,因为Monitor是依赖于底层的操作系统实现,这样就存在用户态与内核态之间的切换,所以会增加性能开销。(执行程序代码是用户态,进入阻塞状态是内核态)

通过对象互斥锁的概念来保证共享数据操作的完整性。每个对象都对应一个可称为 “互斥锁” 的标记这个标记用于保证在任何时刻,只能有一个线程访问对象。

那些处于EntryList与WaitSet中的线程均处于阻塞状态,阻塞操作是有操作系统来完成的,在linux下是通过pthread_mutex_lock函数实现的。线程被阻塞后便进入到内核调度状态,这会导致系统在用户态和内核态之间来回切换,严重影响锁的性能。

解决上述问题的办法便是自旋(spin)。其原理是:当发生对Monitor的争用时,若owner(持有Monitor的线程)能够在很短的时间内释放掉锁,则那些正在争用的线程就可以稍微等待一下(既所谓的自旋),在Owner线程释放锁之后,争用线程可能会立刻获取到锁,从而避免了系统阻塞。不过,当Owner运行的时间超过了临界值后,争用线程自旋一段时间后依然无法获取到锁,这时争用线程则会停止自旋而进入到阻塞状态。所以总体的思想是:先自旋,不成功再进行阻塞,尽量降低阻塞的可能性,这对那些执行时间很短的代码来说有极大的性能提升。显然,自旋在多处理器(多核心)上才有意义 。

4、互斥锁(操作系统层面)

在编程中,引入了对象互斥锁的概念,来保证共享数据操作的完整性。每个对象都对应于一个可称为" 互斥锁" 的标记,这个标记用来保证在任一时刻,只能有一个线程访问该对象

互斥锁的属性在创建锁的时候指定,在LinuxThreads实现中仅有一个锁类型属性,4种锁,每一种锁在Java代码中都对应一种情况

  • 1、PTHREAD_MUTEX_TIMED_NP,这是缺省值,也就是普通锁。当一个线程加锁以后,其余请求锁的线程将形成一个等待队列,并在解锁后按优先级获得锁。这种锁策略保证了资源分配的公平性。

  • 2、PTHREAD_MUTEX_RECURSIVE_NP,嵌套锁,也就是可重入锁,允许同一个线程对同一个锁成功获得多次,并通过多次unlock解锁。如果是不同线程请求,则在加锁线程解锁时重新竞争。(Java绝大部分的锁都是这个可重入锁)

  • 3、PTHREAD_MUTEX_ERRORCHECK_NP,检错锁,如果同一个线程请求同一个锁,则返回EDEADLK,否则与PTHREAD_MUTEX_TIMED_NP类型动作相同。这样就保证当不允许多次加锁时不会出现最简单情况下的死锁。

  • 4、PTHREAD_MUTEX_ADAPTIVE_NP,适应锁,动作最简单的锁类型,仅等待解锁后重新竞争。

5、研究Monitor对象(C++代码实现的)

在Hotspot虚拟机中,Monitor对象对应底层c++是由ObjectMonitor实现的,注意的是,Monitor(也就是c++的ObjectMonitor)是依赖于底层操作系统的mutex lock来实现互斥的,线程获取mutex成功,则会持有该mutex,这时其他线程就无法获取到该mutex。每个对象实例都会有一个Monitor对象,Monitor对象会和Java对象一同创建和销毁。

1、ObjectWaiter类,他扮演着一个代理者或者代理线程,代表等待锁的线程的封装,里面有线程,是一个双向链表的结构
class ObjectWaiter : public StackObj {
 public:
  ...
  ...
  ObjectWaiter * volatile _next;
  ObjectWaiter * volatile _prev;
  Thread*        _thread;
  ...
  ...
}
2、ObjectMonitor类
  • WaitSet:如果线程调用了wait方法,那么该线程就会释放掉所持有的mutex,并且该线程会进入到WaitSet集合(等待集合)中,等待下一次被其他线程调用notify/notifyAll 唤醒。
  • EntryList:当多个线程同时访问一段同步代码时,这些线程会被放到一个EntryList集合中
  • owner:哪个线程正在持有ObjectMonitor对象

WaitSet和EntryList在c++中用环形双向链表实现的好处是:系统可以根据指定的策略来获取某些线程(即对应的ObjectWaiter),用双向链表效率高

class ObjectMonitor {
  ...  
  protected:
    ObjectWaiter * volatile _WaitSet;
  protected:
    ObjectWaiter * volatile _EntryList;
  protected:
     void * volatile _owner;
  ...
}
image.png
3、wait方法的c++具体实现(简易版)
wait(){
    ObjectWaiter node(self);
    AddWaiter(& node);
    exit(true, self);
}

①:将当前线程包装成了ObjectWaiter对象
②:尾插法插入到环形双向链表WaitSet中
③:释放所持有的对象锁

AddWaiter原生代码


image.png
4、notify方法的c++具体实现
image.png image.png

咱们看一下DequeueWaiter()方法是怎么实现的:


image.png

回到主流程:


image.png
其实在notify()的官网中也有类似的说明:
image.png

6、锁升级

从JDK 1.5之前,我们若想实现线程同步,只能通过synchronized关键字这一种方式来达成;底层,Java也是通过synchronized关键字来做到数据的原子性维护的;synchronized关键字是JVM实现的一种内置锁,从底层角度来说,这种锁的获取与释放都是由JVM帮助我们隐式实现的

从JDK 1.5开始,并发包引入了Lock锁,Lock同步锁是基于Java来实现的,因此锁的获取与释放都是通过Java代码来实现与控制的;然而,synchronized是基于底层操作系统的Mutex Lock来实现的,每次对锁的获取与释放动作都会带来用户态与内核态之间的切换(注意:这里指的是当前synchronized锁的都是用重量级锁,先阻塞切换成内核态,再唤醒切换为用户态),这种切换会极大地增加系统的负担;在并发量较高时,也就是说锁的竞争比较激烈时,synchronized锁在性能上的表现就非常差

从JDK 1.6开始,synchronized锁的实现发生了很大的变化;JVM引入了相应的优化手段来提升synchronized锁的性能,这种提升涉及到偏向锁、轻量级锁以及重量级锁,从而减少锁的竞争所带来的用户态与内核态之间的切换;这种锁的优化实际上是通过Java对象头中的一些标志位来实现的;对于锁的访问与改变,实际上都是与Java对象头(可以看对象的创建过程文章)息息相关

对象头主要由3块内容来构成
①:Mark Word
②:指向类的指针
③:数组长度

其中Mark Word (它记录了对象、锁以及垃圾回收相关的信息,在64位的JVM中,其长度也是64bit)的位信息包括了如下组成部分

①:无锁标记
②:偏向锁标记
③:轻量级锁标记
④:重量级锁标记
⑤:GC标记

对于synchronized锁来说,锁的升级主要都是通过Mark Word中的锁标记位与是否是偏向锁标志位来达成的;开始,随着锁竞争的不断升级,逐步演化至轻量级锁,最后则变成了重量级锁。

对于锁的演化来说,它会经历如下阶段:

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

偏向锁:
偏向锁的目的是在某个线程获得锁之后,消除这个线程锁重入(CAS)的开销,看起来让这个线程得到了偏护。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁每次获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令

针对于一个线程来说,它的主要作用是优化同一个线程多次获取一个锁的情况;如果一个synchronized方法被一个线程访问,那么这个方法所在的对象就会在其Mark Word中将偏向锁进行标记,同时还会有一个字段来储存该线程的ID;当这个线程再次访问同一个synchronized方法时,它会检查这个对象的Mark Word的偏向锁标记以及是否指向了其线程的ID,如果是的话,那么该线程就无需再去进入管程(Monitor)了,而是直接进入到该方法体中

轻量级锁:
若第一个线程已经获取到了对象的锁,这是第二个检查又开始尝试争取该对象的锁,由于该对象的锁已经被第一个线程获取到,因此它是偏向锁,而第二个线程在争抢时,会发现该对象头中的Mark Word已经是偏向锁,但里面存储的线程ID并不是自己(是第一个线程),它就会进行CAS(Compare and Swap),从而获取到锁,这里面存在两种情况:
1、获取锁成功:那么它会直接将Mark Word中的线程ID由第一个线程变成自己(偏向锁标记为保持不变),这种该对象依然会保持偏向锁的状态
2、获取锁失败:则表示这时可能有多个线程同时在尝试争抢该对象的锁,那么这时偏向锁就会进行升级,升级为轻量级锁

自旋锁:(是轻量级锁的其中一种实现方式)
若自旋失败(依然无法获取到锁),那么锁就会转化为重量级锁,在这种情况下无法获取到锁的线程都会进入到Monitor(即内核态)

重量级锁
重量级锁是依赖对象内部的monitor锁来实现的,而monitor又依赖操作系统的MutexLock(互斥锁)来实现的,所以重量级锁也称为互斥锁

当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cpu。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。

7、锁消除与锁粗化

1、锁消除

JIT编辑器(Just In Time编辑器,实时编辑器)可以在动态编译同步代码时,使用一种逃逸分析的技术,通过该技术判别程序中所使用的锁对象是否只被一个线程所使用,而没有散步到其他线程当中;如果情况是这样的话,那么JIT编辑器编辑器这个同步代码时就不会生成synchronized关键字锁标识的锁的申请与释放机器码,从而清除了锁的使用流程(注意:字节码层面还是有对应的monitorenter和monitorexit)

例子:

package com.concurrency2;

public class MyTest1 {
    //private Object object = new Object();
    public void method() {
        Object object = new Object();

        synchronized (object) {
            System.out.println("hello world");
        }
    }
}
package com.concurrency2;

public class MyTest1 {
    //private Object object = new Object();
    public void method() {
        Object object = new Object();

        synchronized (object) {
            System.out.println("hello world");
        }

        synchronized (object) {
            System.out.println("welcome");
        }

        synchronized (object) {
            System.out.println("person");
        }
    }
}
2、锁粗化

JIT编辑器在执行动态编译时,若发现前后相邻的synchronized块使用的是同一个锁对象,那么它就会把这几个synchronized块给合并为一个较大的同步块,这样做的好处在于线程在执行这些代码时,就无需频繁申请与释放锁了,从而达到申请与释放锁一次,就可以执行完全部的同步代码块,从而提升了性能

package com.concurrency2;

public class MyTest1 {
    private Object object = new Object();
    public void method() {
        
        synchronized (object) {
            System.out.println("hello world");
        }

        synchronized (object) {
            System.out.println("welcome");
        }

        synchronized (object) {
            System.out.println("person");
        }
    }
}

相关文章

网友评论

    本文标题:synchronized关键字与Monitor对象研究

    本文链接:https://www.haomeiwen.com/subject/qiavoktx.html