java中的锁机制

884 阅读14分钟

synchronized是Java的一个关键字,它能够将代码块(方法)锁起来 使用起来是非常简单的,只要在代码块(方法)添加关键字synchronized,即可以实现同步的功能~

synchronized是一种互斥锁

一次只能允许一个线程进入被锁住的代码块 synchronized是一种内置锁/监视器锁

Java中每个对象都有一个内置锁(监视器,也可以理解成锁标记),而synchronized就是使用对象的内置锁(监视器)来将代码块(方法)锁定的! 以上是对Java的锁机制的一些简单定义和用法;

我们从以下几点探讨以下java的锁机制

1.并发访问
2.为什么要加锁
5.对象锁和类锁的区别
6.synchronized的使用
7.synchronized的实现
8.monitor机制

1.并发访问

操作系统中进程的基本状态

graph LR
创建-->|许可|就绪
就绪-->|得到时间片|执行
执行-->|i/o请求|阻塞
执行-->|时间片完|就绪
阻塞-->|i/o完成|就绪

为什么需要并发访问,首先。我们知道在操作系统中,程序的执行是通过cpu分配时间片来控制的,当一个就绪状态的进程获取了时间片,那么他就会被执行,如果执行状态的进程分配的时间片用完,他就会从执行态转为就绪态,等待cpu重新分配时间片。而我们的cpu一般都是多核的,如果将一个任务分成多线程去跑,那么他的执行效率会大大提升。还有在网络编制中涉及的多用户访问问题,此处不是本文重点,就不多bb了。

2.为什么要加锁

当多个用户操作一些公用资源时,就可能出现数据混乱,一个简单的例子:

package Thread;

/**
 * @Author: sunsuhai
 * @Date: 2018/11/18 22:49
 */
public class Demo implements Runnable {
    
    static int i = 0;
    public static void doSomeing() throws Exception {
        for (int j = 0; j < 1000000; j++) {
                i++;
            }
    }

    @Override
    public void run() {
        try {
            doSomeing( );
        } catch (Exception e) {
            e.printStackTrace( );
        }
    }

    public static void main(String[] args) throws Exception {
        Demo demo = new Demo( );
        Thread t1 = new Thread(demo);
        Thread t2 = new Thread(demo);
        t1.start( );
        t2.start( );
        t1.join( );
        t2.join( );
        System.out.println(Demo.i);
    }
}


这个操作想把i加到2000000,因为开了两个线程跑,每个线程把数加到一万,看上去是没有问题的,很符合逻辑。但是运行结果并不是这样的:

1953293

Process finished with exit code 0

输出的结果并不是2000000,因为i++ 这个操作并不具备原子性和可见性, 什么是原子性,原子性简单来说就是一次操作,,i++ 并不具备原子性因为他是分三步的 1.取出i,2。将i+1,3重新写入,每一步都有可能被中断。 Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

什么是可见性,用我的理解就是本次线程操作的结果对其他线程都是可见的。这句话是啥意思,就是t1线程执行dosomeing方法,他在对i=1时进行i++操作,得到的i应该是2,不过在t1对i=1进行操作时,t2也进行到i=1时了,他并不知道i已经变成2了,他依旧对i=1进行操作,可见性简单理解就是我能保证在i变量操作完毕后,可以让其他线程知道我已经改变这个数了,volatile关键字就能实现可见性,因为他保证了在线程结束前,被volatile修饰的变量能够写入内存 ,当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

  而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

  另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

我们可以通过volatile和简单的信号量机制来实现刚才的demo

package Thread;

/**
 * @Author: sunsuhai
 * @Date: 2018/11/18 22:49
 */
public class Demo implements Runnable {

    volatile static boolean flag = true;
    static int i = 0;

    public static void doSomeing() throws Exception {

        Thread.sleep(1000);
        if (flag) {
            flag = false;
            for (int j = 0; j < 1000000; j++) {

                i++;
            }
            flag = true;

        }


    }

    @Override
    public void run() {
        try {
            doSomeing( );
        } catch (Exception e) {
            e.printStackTrace( );
        }
    }

    public static void main(String[] args) throws Exception {
        Demo demo = new Demo( );
        Thread t1 = new Thread(demo);
        Thread t2 = new Thread(demo);
        t1.start( );
        t2.start( );
        t1.join( );
        t2.join( );
        System.out.println(Demo.i);
    }
}

输出结果:

2000000

Process finished with exit code 0

使用flag来模仿操作系统中的整形信号量,实现同步机制。

5.对象锁和类锁的区别

其实也没啥区别,因为类本来就是class的对象,说白了类也是个对象,对象锁是用于对象实例方法,或者一个对象实例上的,类锁是用于类的静态方法或者一个类的class对象上的。我们知道,类的对象实例可以有很多个,但是每个类只有一个class对象,所以不同对象实例的对象锁是互不干扰的,但是每个类只有一个类锁。但是有一点必须注意的是,其实类锁只是一个概念上的东西,并不是真实存在的,它只是用来帮助我们理解锁定实例方法和静态方法的区别的;

synchronized的使用

synchronized的用法:synchronized修饰方法和synchronized修饰代码块。 其实这两种用法都是相似的,都是为了同步某一段代码,不过修饰方法时包围的代码太多,也被称为粗粒度锁,举个简单的例子:

办理升职加薪手续{
    1.进入办公室 - 用时1000ms
    2.填表 - 用时100000ms
    3.审核 - 用时200000ms
    4.盖章 - 用时5000ms
    5.拿钱走人 - 用时2ms
    6.出办公室 - 用时1000ms
}

上面那个例子,其实需要加锁的只有拿钱那一个步骤,反而拿钱的步骤是用时最少的步骤。加锁我们可以看成是一个派对的过程,如果我们让所有人在办公室门口排队,就是synchronized修饰方法,那不是扯淡吗,多浪费时间 我们完全可以在拿钱那一个步骤上加锁,省事。 这就是synchronized修饰方法和代码块的区别。 其实他俩都有针对类锁和对象锁的不同操作,我们拿修饰方法来分析: 对象锁的实例:

package Thread;

/**
 * @Author: sunsuhai
 * @Date: 2018/11/18 20:14
 */
public class SyncObject implements Runnable{

    //共享资源(临界资源)
    int i=0;

    public int getI() {
        return i;
    }

    /**
     * synchronized 修饰实例方法
     */
    public synchronized void increase(){
        i++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        SyncObject instance=new SyncObject();
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(instance.getI());
    }

}

类锁的实例:

package Thread;

/**
 * @Author: sunsuhai
 * @Date: 2018/11/18 20:17
 */
public class SyncClass implements Runnable{
    static int i=0;
    public  void increase(){
        synchronized(SyncClass.class) {
            i++;
        }
    }
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {

        Thread t1=new Thread(new SyncClass());

        Thread t2=new Thread(new SyncClass());
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(i);
    }
}

synchronized用法总结:

修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁

修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁

修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码前要获得给定对象的锁。

对于用法没啥可说的,我们来看一下synchronized的实现,嘿嘿嘿

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下:

1.实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐,这点了解即可。

2.填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。

3.重头戏,对象头的结构它实现synchronized的锁对象的基础,这点我们重点分析它,一般而言,synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构是由Mark Word 和 Class Metadata Address 组成 我们只需要知道这里我们主要分析一下重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 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 ;
  }

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

graph LR
_EntryList-->_owner
_owner-->_WaitSet
_WaitSet-->_owner

由此看来,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因(关于这点稍后还会进行分析),ok~,有了上述知识基础后,下面我们将进一步分析synchronized在字节码层面的具体语义实现。

synchronized代码块底层原理 我们对SyncObject进行反编译后得到

 public synchronized void increase();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field i:I
         5: iconst_1
         6: iadd
         7: putfield      #2                  // Field i:I
        10: return
      LineNumberTable:
        line 20: 0
        line 21: 10

  public void increase2();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: aload_0
         5: dup
         6: getfield      #2                  // Field i:I
         9: iconst_1
        10: iadd
        11: putfield      #2                  // Field i:I
        14: aload_1
        15: monitorexit
        16: goto          24
        19: astore_2
        20: aload_1
        21: monitorexit
        22: aload_2
        23: athrow
        24: return
      Exception table:
         from    to  target type
             4    16    19   any
            19    22    19   any
      LineNumberTable:
        line 23: 0
        line 24: 4
        line 25: 14
        line 26: 24
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 19
          locals = [ class Thread/SyncObject, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4

从字节码中可知同步语句块的实现使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。

方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。从字节码中可以看出,synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

synchronized的可重入性

从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。