并发基础(七) 锁-synchronize关键字

一、synchronized基本使用

上篇文章详细讲解了volatile关键字,我们知道volatile关键字可以保证共享变量的可见性和有序性,但并不能保证原子性。如果既想保证共享变量的可见性和有序性,又想保证原子性,那么synchronized关键字是一个不错的选择。

synchronized的使用很简单,可以用它来修饰实例方法和静态方法,也可以用来修饰代码块。值的注意的是synchronized是一个对象锁,也就是它锁的是一个对象。因此,无论使用哪一种方法,synchronized都需要有一个锁对象

1.修饰实例方法

synchronized修饰实例方法只需要在方法上加上synchronized关键字即可。

arduino 复制代码
public synchronized void add(){
       i++;
}

此时,synchronized加锁的对象就是这个方法所在实例的本身。

2.修饰静态方法

synchronized修饰静态方法的使用与实例方法并无差别,在静态方法上加上synchronized关键字即可

arduino 复制代码
public static synchronized void add(){
       i++;
}

此时,synchronized加锁的对象为当前静态方法所在类的Class对象。

3.修饰代码块

synchronized修饰代码块需要传入一个对象。

csharp 复制代码
public void add() {f
    synchronized (this) {
        i++;
    }
}

二、Java对象头与Monitor对象

oop-klass

简单地说,一个Java类在JVM中被拆分为了两个部分:数据和描述信息,分别对应OOP和Klass。OOP表示java对象应该承载的数据,而Klass表示描述对象有多大,函数地址,对象大小,静态区域大小。

oop

Ordinary Object Pointer (普通对象指针),它用来表示对象的实例信息,看起来像个指针实际上是藏在指针里的对象。Klass是在class文件在加载过程中创建的,OOP则是在Java程序运行过程中new对象时创建的。

一个OOP对象包含以下几个部分:

Klass

Klass包含元数据和方法信息,用来描述Java类。 Klass主要有两个功能:

  • 实现语言层面的Java类
  • 实现Java对象的分发功能

一般jvm在加载class文件时,会在方法区创建InstanceKlass,表示其元数据,包括常量池、字段、方法等。

这里着重介绍InstanceKlass的两个字段:

  • _prototype_header :原型头,用于用于标识Mark Word 原型,在对象被创建出来以后,会从***_prototype_header*** 拷贝数据到对象头的Mark Word中。
  • revocation_count:撤销计数器,每次该class的对象发生偏向锁撤销操作时,计数器会自增1,当达到批量重偏向阈值(默认20)时,会执行批量重偏向;当达到批量撤销的阈值(默认40)时,会执行批量撤销。
java 复制代码
class Model
{
    public static int a = 1;
    public int b;

    public Model(int b) {
        this.b = b;
    }
}

public static void main(String[] args) {
    int c = 10;
    Model modelA = new Model(2);
    Model modelB = new Model(3);
}

上述代码的OOP-Klass模型如下所示

在JVM中,对象在内存中存储的布局可以分为三个区域,分别是对象头、实例数据以及填充数据。

  • 实例数据 存放类的属性数据信息,包括父类的属性信息,这部分内存按4字节对齐。
  • 填充数据 由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
  • 对象头 在HotSpot虚拟机中,对象头又被分为两部分,分别为:Mark Word(标记字段)、Class Pointer(类型指针)。如果是数组,那么还会有数组长度。

1.对象头

java的对象头由以下三部分组成:

1,Mark Word

2,指向类的指针

3,数组长度(只有数组对象才有)

1.1 Mark Word

Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。

Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。 Mark Word 在32位 JVM 中:

Mark Word 在64位 JVM 中:

  • 锁标志位(lock) 区分锁状态,11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。

  • biased_lock 是否偏向锁,由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。

  • 分代年龄(age) 表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。

  • 对象的hashcode(hash) 运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。当对象加锁后,计算的结果31位不够表示,在偏向锁,轻量锁,重量锁,hashcode会被转移到Monitor中。

  • 偏向锁的线程ID(JavaThread): 偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作。

  • epoch 偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。

  • ptr_to_lock_record 轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争的时,JVM使用原子操作而不是OS互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的标题字中设置指向锁记录的指针。

  • ptr_to_heavyweight_monitor 重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针。

1.2 指向类的指针

该指针在32位JVM中的长度是32bit,在64位JVM中长度是64bit。

Java对象的类数据保存在方法区。

1.3 数组长度

只有数组对象保存了这部分数据。

该数据在32位和64位JVM中长度都是32bit。

2.Monitor对象

Monitor对象被称为管程或者监视器锁。在Java中,每一个对象实例都会关联一个Monitor对象。这个Monitor对象既可以与对象一起创建销毁,也可以在线程试图获取对象锁时自动生成。当这个Monitor对象被线程持有后,它便处于锁定状态。

在HotSpot虚拟机中,Monitor是由ObjectMonitor实现的,它是一个使用C++实现的类,主要数据结构如下:

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 ; // 阻塞队列,线程被唤醒后根据决策判读是放入cxq还是EntryList
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 没有抢到锁的线程会被放到这个队列
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

ObjectMonitor中有五个重要部分,分别为_ower,_WaitSet,_cxq,_EntryList和count。

  • _ower 用来指向持有monitor的线程,它的初始值为NULL,表示当前没有任何线程持有monitor。当一个线程成功持有该锁之后会保存线程的ID标识,等到线程释放锁后_ower又会被重置为NULL;
  • _WaitSet 调用了锁对象的wait方法后的线程会被加入到这个队列中;
  • _cxq 是一个阻塞队列,线程被唤醒后根据决策判读是放入cxq还是EntryList;
  • _EntryList 没有抢到锁的线程会被放到这个队列;
  • count 用于记录线程获取锁的次数,成功获取到锁后count会加1,释放锁时count减1。
  1. 当多个线程同时访问同步代码块时,首先会进入到EntryList中,然后通过CAS的方式尝试将Monitor中的owner字段设置为当前线程,同时count加1,若发现之前的owner的值就是指向当前线程的,recursions也需要加1。如果CAS尝试获取锁失败,则进入到EntryList中。
  2. 当获取锁的线程调用wait()方法,则会将owner设置为null,同时count减1,recursions减1,当前线程加入到WaitSet中,等待被唤醒。
  3. 当前线程执行完同步代码块时,则会释放锁,count减1,recursions减1。当recursions的值为0时,说明线程已经释放了锁。

三、synchronized底层实现原理

在JDK1.6之前,synchronized属于重量级,是一个效率比较低下的锁,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,挂起线程和恢复线程都需要转入内核态去完成,阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,时间成本相对较高。

在JDK1.6后,JVM为了提高锁的获取与释放效率对 synchronized 进行了优化,引入了偏向锁和轻量级锁,所以锁的状态就有了四种(无锁、偏向锁、轻量级锁、重量级锁),会随着竞争的激烈而逐渐升级。

1. 锁的分类

锁状态 存储内容 标志位
无锁 对象的hashCode、对象分代年龄、是否是偏向锁(0) 01
偏向锁 向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1) 01
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向互斥量的指针 11

1.1 无锁

无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的特点是修改操作会在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。

1.2 偏向锁

偏向锁,顾名思义,它会偏向于第一个访问锁的线程

  • 如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。线程第二次到达同步代码块时,会判断此时持有锁的线程是否就是自己,如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
  • 如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。偏向锁通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致STW(stop the word)操作;

初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是"偏向于第一个获得它的线程"的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。

偏向锁是指当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,即提高性能。

当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。

关于偏向锁的撤销,需要等待全局安全点,即在某个时间点上没有字节码正在执行时,它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态。

1.3 轻量级锁(自旋锁)

轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋(关于自旋的介绍见文末)的形式尝试获取锁,线程不会阻塞,从而提高性能。

轻量级锁的获取主要由两种情况:

① 当关闭偏向锁功能时;

② 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。

一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。

在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为"释放",如果是则将其设置为"锁定",比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。

长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。

1.4 重量级锁

重量级锁显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。

重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。

简言之,就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资

实现原理

csharp 复制代码
    public void run() {
        //其他操作.......
        synchronized (this){   //this表示当前对象实例,这里还可以使用syncTest.class,表示class对象锁
            for (int j = 0; j < 10000; j++) {
                i++;
            }
        }

    }

查看代码字节码指令如下:

csharp 复制代码
 1 dup
 2 astore_1
 3 monitorenter     //进入同步代码块的指令
 4 iconst_0
 5 istore_2
 6 iload_2
 7 sipush 10000
10 if_icmpge 27 (+17)
13 getstatic #2 <com/company/syncTest.i>
16 iconst_1
17 iadd
18 putstatic #2 <com/company/syncTest.i>
21 iinc 2 by 1
24 goto 6 (-18)
27 aload_1
28 monitorexit     //结束同步代码块的指令
29 goto 37 (+8)
32 astore_3
33 aload_1
34 monitorexit     //遇到异常时执行的指令
35 aload_3
36 athrow
37 return

从上述字节码中可以看到同步代码块的实现是由monitorentermonitorexit指令完成的,其中monitorenter指令所在的位置是同步代码块开始的位置,第一个monitorexit指令是用于正常结束同步代码块的指令,第二个monitorexit指令是用于异常结束时所执行的释放Monitor指令。

synchronized作用于同步方法原理

arduino 复制代码
    private synchronized void add() {
        i++;
    }

查看字节码如下:

bash 复制代码
0 getstatic #2 <com/company/syncTest.i>
3 iconst_1
4 iadd
5 putstatic #2 <com/company/syncTest.i>
8 return

发现这个没有monitorentermonitorexit 这两个指令了,而在查看该方法的class文件的结构信息时发现了Access flags后边的synchronized标识,该标识表明了该方法是一个同步方法。Java虚拟机通过该标识可以来辨别一个方法是否为同步方法,如果有该标识,线程将持有Monitor,在执行方法,最后释放Monitor。

原理大概就是这样,最后总结一下,面试中应该简洁地如何回答synchroized的底层原理这个问题。

答:Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,代码块同步使用的是monitorentermonitorexit 指令实现的,而方法同步是通过Access flags后面的标识来确定该方法是否为同步方法。

相关推荐
哎呦没7 分钟前
Spring Boot与林业产品推荐系统的融合
java·spring boot·后端
大梦百万秋8 分钟前
云原生后端开发:构建现代化可扩展的服务
后端
gorgor在码农9 分钟前
redis 底层数据结构
java·数据库·redis
Erosion202017 分钟前
JAVA WEB和Tomcat各组件概念
java·tomcat
G丶AEOM19 分钟前
JVM中TLAB(线程本地分配缓存区)是什么
java·jvm
《源码好优多》23 分钟前
基于Java Springboot华为数码商城交易平台
java·开发语言·spring boot
往日情怀酿做酒 V176392963832 分钟前
Django基础之路由
后端·python·django
桃园码工1 小时前
第三章:基本语法 2.变量和常量 --Go 语言轻松入门
后端·golang·go语言
♡喜欢做梦1 小时前
【Java】二叉树:数据海洋中灯塔式结构探秘(下:基本操作)
java·数据结构·算法
Allen Bright1 小时前
Java代码操作Zookeeper(使用 Apache Curator 库)
java·zookeeper·java-zookeeper