快速掌握并发编程---synchronized篇(下)

时间:2022-07-28
本文章向大家介绍快速掌握并发编程---synchronized篇(下),主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

昨天聊了Synchronized的部分知识点快速掌握并发编程---synchronized篇(上),今天,接着聊聊 Synchronized的其他重要知识点。

Synchronized 锁重入

关键字 Synchronized 拥有锁重入的功能,也就是在使用 Synchronized 的时候,当一个线程得到一个对象的锁后,在该锁里执行代码的时候可以再次请求该对象的锁时可以再次得到该对象的锁。也就是说,当线程请求一个由其它线程持有的对象锁时,该线程会阻塞,而当线程请求由自己持有的对象锁时,如果该锁是重入锁,请求就会成功,否则阻塞。

一个简单的例子就是:

在一个 Synchronized 修饰的方法,或代码块的内部调用本类的其他 Synchronized 修饰的方法或代码块时,永远可以得到锁。

public class SynchronizedDemo{

    public synchronized  void test1() {
        System.out.println("----test1------");
    }
    public synchronized  void test2() {
        System.out.println("----test2------");
        test1();
    }

    public static void main(String[] args) throws Exception {
        SynchronizedDemo synchronizedDemo =new SynchronizedDemo();
          new Thread(new Runnable() {
              @Override
              public void run() {
                  synchronizedDemo.test2();
              }
          }).start();
    }
}

输出

----test2------
----test1------

为什么要引入可重入锁这种机制?

假如有一个线程 T 获得了对象 A 的锁,那么该线程 T 如果在未释放前再次请求该对象的锁时,如果没有可重入锁的机制,是不会获取到锁的,这样的话就会出现死锁的情况。

就如上面代码那样,线程 T 在执行到test2()内部的时候,由于该线程已经获取了该对象 synchronizedDemo的对象锁,当执行到调用test1() 的时候,会再次请求该对象的对象锁,如果没有可重入锁机制的话,由于该线程 T 还未释放在刚进入test2() 时获取的对象锁,当执行到调用test1() 的时候,就会出现死锁。

可重入的作用

最大可能的防止发生死锁

synchronized实现原理

先看一段代码

package com.java.tian.blog.utils;

public class SynchronizedDemo{

    public synchronized  void test1() {
    }
    public   void test2() {
        synchronized(this){

        }
    }
}

编译后,找到该类class文件目录,然后

javap -verbose SynchronizedDemo.class >test.txt

可以把对应的字节码疏导test.txt中。

从上面可以看出,同步代码块是使用monitorenter和monitorexit指令实现的,同步方法(在这看不出来需要看JVM底层实现)依靠的是方法修饰符上的ACC_SYNCHRONIZED实现。

同步代码块

monitorenter指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置,JVM需要保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个monitor与之相关联,当且一个monitor被持有之后,他将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权,即尝试获取对象的锁;

虚拟机在执行 monitorenter 指令时,会先尝试获取该对象的锁,如果该对象已被当前线程锁定,那么这次加锁算是一次重入,就将锁的计数器加 1;

在执行 monitorexit 指令时,如果计数器大于 0,表示当前锁有重入,则解锁就是简单地将计数器减 1,直到计数器为 0(表示重入的锁都已经释放),才开始真正的锁释放操作,锁完全释放后,其它的线程才能参与锁的竞争。

同步方法

synchronized方法则会被翻译成普通的方法调用和返回指令,如:invokevirtual、areturn指令,在JVM字节码层面并没有任何特别的指令来实现被synchronized修饰的方法,而是在Class文件的方法表中将该方法的access_flags字段中的synchronized标志位置1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示Class做为锁对象。JVM 可以根据该标志得知该方法是一个同步方法,然后再方法执行前进行 monitor 的锁定操作,也就相当于一个隐式的实现。

不管是计数还是标志位置,那么总得有个地方存放吧。既然synchronized是针对对象的,那么由此可以猜测肯定是存在在对象里的。

细说对象内存布局

对象在内存中的存储可以分为 3 块区域,分别是对象头、实例数据和对齐填充。

其中,对象头包括两部分内容,一部分是对象本身的运行时数据,像 GC 分代年龄、哈希码、锁状态标识等等,官方称之为“Mark Word”,如果忽略压缩指针的影响,这部分数据在 32 位和 64 位的虚拟机中分别占 32 位和 64 位。但是对象需要存储的运行时数据很多,32 位或者 64 位都不一定能存的下,考虑到虚拟机的空间效率,这个 Mark Word 被设计成一个非固定的数据结构,它会根据对象的状态复用自己的存储空间,对象处于不同状态的时候,对应的 bit 表示的含义可能会不一样,见下图(来源于网络),以 32 位 Hot Spot 虚拟机为例:

从上图中我们可以看出,如果对象处于未锁定状态(无锁态),那么 Mark Word 的 25 位用于存储对象的哈希码,4 位用于存储对象分代年龄,1 位固定为 0,两位用于存储锁标志位。这个图对于理解后面提到的轻量级锁、偏向锁是非常重要的,当然我们现在可以先着重考虑对象处于重量级锁状态下的情况,也就是锁标志位为 10。同时我们看到,无锁态和偏向锁状态下,2 位锁标志位都是“01”,留有 1 位表示是否可偏向,我们姑且叫它“偏向位”。

:对象头的另一部分则是类型指针,虚拟机可以通过这个指针来确认该对象是哪个类的实例。但是我们要注意,并不是所有的虚拟机都必须以这种方式来确定对象的元数据信息。对象的访问定位一般有句柄和直接指针两种,如果使用句柄的话,那么对象的元数据信息可以直接包含在句柄中(当然也包括对象实例数据的地址信息),也就没必要将这些元数据和实例数据存储在一起了。至于实例数据和对齐填充,这里暂不做讨论。

前面我们提到了,Java 中的每个对象都与一个 monitor 相关联,当锁标志位为 10 时,除了 2bit 的标志位,指向的就是 monitor 对象的地址(还是以 32 位虚拟机为例)。这里我们可以翻阅一下 OpenJDK 的源码,如果我们需要下载openJDK的源码:

参考:https://www.cnblogs.com/jpfss/p/10936167.html

更多的信息可以在http://hg.openjdk.java.net

找到。这里先看一下markOpp.hpp文件。该文件的相对路径为:

openjdkhotspotsrcsharevmoops

下图是文件中的注释部分:

我们可以看到,其中描述了 32 位和 64 位下 Mark World 的存储状态。也可以看到64位下,前25位是没有使用的。

我们也可以看到 markOop.hpp 中定义的锁状态枚举,对应我们前面提到的无锁、偏向锁、轻量级锁、重量级锁(膨胀锁)、GC 标记等:

  enum { locked_value             = 0,//00 轻量级锁
         unlocked_value           = 1,//01 无锁
         monitor_value            = 2,//10 重量级锁
         marked_value             = 3,//11 GC标记
         biased_lock_pattern      = 5 //101 偏向锁,1位偏向标记和2位状态标记(01)
  };

从注释中,我们也可以看到对其的简要描述,后面会我们详细解释:

这里我们的重心还是是重量级锁,所以我们看看源码中 monitor 对象是如何定义的,对应的头文件是 objectMonitor.hpp,文件路径为:

openjdkhotspotsrcsharevmruntime

我们来简单看一下这个 objectMonitor.hpp 的定义:

  // initialize the monitor, exception the semaphore, all other fields
  // are simple integers or pointers
  ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,//等待线程数
    _recursions   = 0;//重入次数
    _object       = NULL;
    _owner        = NULL;//持有锁的线程(逻辑上,实际上除了THREAD,还可能是Lock Record)
    _WaitSet      = NULL;//线程wait之后会进入该列表
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;//等待获取锁的线程列表,和_EntryList配合使用
    FreeNext      = NULL ;
    _EntryList    = NULL ;//等待获取锁的线程列表,和_cxq配合使用
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;//当前持有者是否为THREAD类型,如果是轻量级锁膨胀而来,还没有enter的话,
                       //_owner存储的可能会是Lock Record
    _previous_owner_tid = 0;
  }

简单的说,当多个线程竞争访问同一段同步代码块时,如果线程获取到了 monitor,那么就会把 _owner 设置成当前线程,如果是重入的话,_recursions 会加 1,如果获取 monitor 失败,则会进入 _cxq队列。

锁被释放时,_cxq中的线程会被移动到 _EntryList中,并且唤醒_EntryList 队首线程。当然,选取唤醒线程有几个不同的策略(Knob_QMode),还是后面结合源码解析。

_cxq_EntryList本质上是ObjectWaiter 类型,它本质上其实是一个双向链表 (具有前后指针),只是在使用的时候不一定要当做双向链表使用,比如 _cxq 是当做单向链表使用的,_EntryList是当做双向链表使用的。