synchronized 这个关键字的重要性不言而喻,几乎可以说是并发、多线程必须会问到的关键字了。synchronized 会涉及到锁、升级降级操作、锁的撤销、对象头等。所以理解 synchronized 非常重要,本篇文章就带你从 synchronized 的基本用法、再到 synchronized 的深入理解,对象头等,为你揭开 synchronized 的面纱。

1. 浅析 synchronized

synchronized 是 Java 并发模块 非常重要的关键字,它是 Java 内建的一种同步机制,代表了某种内在锁定的概念,当一个线程对某个共享资源加锁后,其他想要获取共享资源的线程必须进行等待,synchronized 也具有互斥和排他的语义。

什么是互斥?我们想必小时候都玩儿过磁铁,磁铁会有正负极的概念,同性相斥异性相吸,相斥相当于就是一种互斥的概念,也就是两者互不相容。

synchronized 也是一种独占的关键字,但是它这种独占的语义更多的是为了增加线程安全性,通过独占某个资源以达到互斥、排他的目的。

在了解了排他和互斥的语义后,我们先来看一下 synchronized 的用法,先来了解用法,再来了解底层实现。

2. synchronized 的使用

关于 synchronized 想必你应该都大致了解过:

  • synchronized 修饰实例方法,相当于是对类的实例进行加锁,进入同步代码前需要获得当前实例的锁;

  • synchronized 修饰静态方法,相当于是对类对象进行加锁;

  • synchronized 修饰代码块,相当于是给对象进行加锁,在进入代码块前需要先获得对象的锁。

下面我们针对每个用法进行解释

2.1 synchronized 修饰实例方法

synchronized 修饰实例方法,实例方法是属于类的实例。synchronized 修饰的实例方法相当于是对象锁。下面是一个 synchronized 修饰实例方法的例子。

public synchronized void method()
{// ...
}

像如上述 synchronized 修饰的方法就是实例方法,下面我们通过一个完整的例子来认识一下 synchronized 修饰实例方法:

public class TSynchronized implements Runnable{static int i = 0;public synchronized void increase(){i++;System.out.println(Thread.currentThread().getName());}@Overridepublic void run() {for(int i = 0;i < 1000;i++) {increase();}}public static void main(String[] args) throws InterruptedException {TSynchronized tSynchronized = new TSynchronized();Thread aThread = new Thread(tSynchronized);Thread bThread = new Thread(tSynchronized);aThread.start();bThread.start();aThread.join();bThread.join();System.out.println("i = " + i);}
}

上面输出的结果 i = 2000 ,并且每次都会打印当前现成的名字

来解释一下上面代码,代码中的 i 是一个静态变量,静态变量也是全局变量,静态变量存储在方法区中。

increase 方法由 synchronized 关键字修饰,但是没有使用 static 关键字修饰,表示 increase 方法是一个实例方法,每次创建一个 TSynchronized 类的同时都会创建一个 increase 方法,increase 方法中只是打印出来了当前访问的线程名称。

Synchronized 类实现了 Runnable 接口,重写了 run 方法,run 方法里面就是一个 0 - 1000 的计数器,这个没什么好说的。

在 main 方法中,new 出了两个线程,分别是 aThread 和 bThread,Thread.join 表示等待这个线程处理结束。这段代码主要的作用就是判断 synchronized 修饰的方法能够具有独占性。

2.2 synchronized 修饰静态方法

synchronized 修饰静态方法就是 synchronized 和 static 关键字一起使用:

public static synchronized void increase(){}

当 synchronized 作用于静态方法时,表示的就是当前类的锁,因为静态方法是属于类的,它不属于任何一个实例成员,因此可以通过 class 对象控制并发访问。

这里需要注意一点,因为 synchronized 修饰的实例方法是属于实例对象,而 synchronized 修饰的静态方法是属于类对象,所以调用 synchronized 的实例方法并不会阻止访问 synchronized 的静态方法。

2.3 synchronized 修饰代码块

synchronized 除了修饰实例方法和静态方法外,synchronized 还可用于修饰代码块,代码块可以嵌套在方法体的内部使用。

public void run() {synchronized(obj){for(int j = 0;j < 1000;j++){i++;}}
}

上面代码中将 obj 作为锁对象对其加锁,每次当线程进入 synchronized 修饰的代码块时就会要求当前线程持有obj 实例对象锁,如果当前有其他线程正持有该对象锁,那么新到的线程就必须等待。

synchronized 修饰的代码块,除了可以锁定对象之外,也可以对当前实例对象锁、class 对象锁进行锁定:

// 实例对象锁
synchronized(this){for(int j = 0;j < 1000;j++){i++;}
}//class对象锁
synchronized(TSynchronized.class){for(int j = 0;j < 1000;j++){i++;}
}

3. synchronized 底层原理

在简单介绍完 synchronized 之后,我们就来聊一下 synchronized 的底层原理了。

我们或许都有所了解(下文会细致分析),synchronized 的代码块是由一组 monitorenter/monitorexit 指令实现的。而Monitor 对象是实现同步的基本单元。

啥是 Monitor 对象呢?

3.1 Monitor 对象

任何对象都关联了一个管程,管程就是控制对象并发访问的一种机制。管程是一种同步原语,在 Java 中指的就是 synchronized,可以理解为 synchronized 就是 Java 中对管程的实现。

管程提供了一种排他访问机制,这种机制也就是 互斥。互斥保证了在每个时间点上,最多只有一个线程会执行同步方法。

所以你理解了 Monitor 对象其实就是使用管程控制同步访问的一种对象。

3.2 对象内存布局

在 HotSpot 虚拟机中,对象在内存中的布局分为三块区域:

  • 对象头(Header)

  • 实例数据(Instance Data)

  • 对齐填充(Padding)

这三块区域的内存分布如下图所示:

我们来详细介绍一下上面对象中的内容。

对象头 Header

对象头 Header 主要包含 MarkWord 和对象指针 Klass Pointer,如果是数组的话,还要包含数组的长度。

在 32 位的虚拟机中 MarkWord ,Klass Pointer 和数组长度分别占用 32 位,也就是 4 字节。

如果是 64 位虚拟机的话,MarkWord ,Klass Pointer 和数组长度分别占用 64 位,也就是 8 字节。

在 32 位虚拟机和 64 位虚拟机的 Mark Word 所占用的字节大小不一样,32 位虚拟机的 Mark Word 和 Klass Pointer 分别占用 32 bits 的字节,而 64 位虚拟机的 Mark Word 和 Klass Pointer 占用了64 bits 的字节,下面我们以 32 位虚拟机为例,来看一下其 Mark Word 的字节具体是如何分配的。

用中文翻译过来就是:

  • 无状态也就是无锁的时候,对象头开辟 25 bit 的空间用来存储对象的 hashcode ,4 bit 用于存放分代年龄,1 bit 用来存放是否偏向锁的标识位,2 bit 用来存放锁标识位为 01。

  • 偏向锁中划分更细,还是开辟 25 bit 的空间,其中 23 bit 用来存放线程ID,2bit 用来存放 epoch,4bit 存放分代年龄,1 bit 存放是否偏向锁标识, 0 表示无锁,1 表示偏向锁,锁的标识位还是 01。

  • 轻量级锁中直接开辟 30 bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为 00。

  • 重量级锁中和轻量级锁一样,30 bit 的空间用来存放指向重量级锁的指针,2 bit 存放锁的标识位,为 11

  • GC 标记开辟 30 bit 的内存空间却没有占用,2 bit 空间存放锁标志位为 11。

其中无锁和偏向锁的锁标志位都是 01,只是在前面的 1 bit 区分了这是无锁状态还是偏向锁状态。

关于为什么这么分配的内存,我们可以从 OpenJDK 中的markOop.hpp类中的枚举窥出端倪:

来解释一下:

  • age_bits 就是我们说的分代回收的标识,占用 4 字节;

  • lock_bits 是锁的标志位,占用 2 个字节;

  • biased_lock_bits 是是否偏向锁的标识,占用 1 个字节;

  • max_hash_bits 是针对无锁计算的 hashcode 占用字节数量,如果是 32 位虚拟机,就是 32 - 4 - 2 -1 = 25 byte,如果是 64 位虚拟机,64 - 4 - 2 - 1 = 57 byte,但是会有 25 字节未使用,所以 64 位的 hashcode 占用 31 byte;

  • hash_bits 是针对 64 位虚拟机来说,如果最大字节数大于 31,则取 31,否则取真实的字节数;

  • cms_bits 我觉得应该是不是 64 位虚拟机就占用 0 byte,是 64 位就占用 1byte;

  • epoch_bits 就是 epoch 所占用的字节大小,2 字节。

在上面的虚拟机对象头分配表中,我们可以看到有几种锁的状态:无锁(无状态),偏向锁,轻量级锁,重量级锁,其中轻量级锁和偏向锁是 JDK1.6 中对 synchronized 锁进行优化后新增加的,其目的就是为了大大优化锁的性能,所以在 JDK 1.6 中,使用 synchronized 的开销也没那么大了。其实从锁有无锁定来讲,还是只有无锁和重量级锁,偏向锁和轻量级锁的出现就是增加了锁的获取性能而已,并没有出现新的锁。

所以我们的重点放在对 synchronized 重量级锁的研究上,当 monitor 被某个线程持有后,它就会处于锁定状态。在 HotSpot 虚拟机中,monitor 的底层代码是由 ObjectMonitor 实现的,其主要数据结构如下(位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件,C++ 实现的)

这段 C++ 中需要注意几个属性:_WaitSet 、 _EntryList 和 _Owner,每个等待获取锁的线程都会被封装称为 ObjectWaiter 对象。

_Owner 是指向了 ObjectMonitor 对象的线程,而 _WaitSet 和 _EntryList 就是用来保存每个线程的列表。

那么这两个列表有什么区别呢?这个问题我和你聊一下锁的获取流程你就清楚了。

锁的两个列表

当多个线程同时访问某段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 monitor 之后,就会进入 _Owner 区域,并把 ObjectMonitor 对象的 _Owner 指向为当前线程,并使 _count + 1,如果调用了释放锁(比如 wait)的操作,就会释放当前持有的 monitor ,owner = null, _count - 1,同时这个线程会进入到 _WaitSet 列表中等待被唤醒。如果当前线程执行完毕后也会释放 monitor 锁,只不过此时不会进入 _WaitSet 列表了,而是直接复位 _count 的值。

Klass Pointer 表示的是类型指针,也就是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

你可能不是很理解指针是个什么概念,你可以简单理解为指针就是指向某个数据的地址。

实例数据 Instance Data

实例数据部分是对象真正存储的有效信息,也是代码中定义的各个字段的字节大小,比如一个 byte 占 1 个字节,一个 int 占用 4 个字节。

对齐 Padding

对齐不是必须存在的,它只起到了占位符(%d, %c 等)的作用。这就是 JVM 的要求了,因为 HotSpot JVM 要求对象的起始地址必须是 8 字节的整数倍,也就是说对象的字节大小是 8 的整数倍,不够的需要使用 Padding 补全。

4. 锁的升级流程

先来个大体的流程图来感受一下这个过程,然后下面我们再分开来说:

4.1 无锁

无锁状态,无锁即没有对资源进行锁定,所有的线程都可以对同一个资源进行访问,但是只有一个线程能够成功修改资源。

无锁的特点就是在循环内进行修改操作,线程会不断的尝试修改共享资源,直到能够成功修改资源并退出,在此过程中没有出现冲突的发生,这很像我们在之前文章中介绍的 CAS 实现,CAS 的原理和应用就是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。

4.2 偏向锁

HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能。

可以从对象头的分配中看到,偏向锁要比无锁多了线程ID 和 epoch,下面我们就来描述一下偏向锁的获取过程。

偏向锁获取过程

  1. 首先线程访问同步代码块,会通过检查对象头 Mark Word 的锁标志位判断目前锁的状态,如果是 01,说明就是无锁或者偏向锁,然后再根据是否偏向锁 的标示判断是无锁还是偏向锁,如果是无锁情况下,执行下一步;

  2. 线程使用 CAS 操作来尝试对对象加锁,如果使用 CAS 替换 ThreadID 成功,就说明是第一次上锁,那么当前线程就会获得对象的偏向锁,此时会在对象头的 Mark Word 中记录当前线程 ID 和获取锁的时间 epoch 等信息,然后执行同步代码块。

全局安全点(Safe Point):全局安全点的理解会涉及到 C 语言底层的一些知识,这里简单理解 SafePoint 是 Java 代码中的一个线程可能暂停执行的位置。

等到下一次线程在进入和退出同步代码块时就不需要进行 CAS 操作进行加锁和解锁,只需要简单判断一下对象头的 Mark Word 中是否存储着指向当前线程的线程ID,判断的标志当然是根据锁的标志位来判断的。如果用流程图来表示的话就是下面这样:

关闭偏向锁

偏向锁在 Java 6 和 Java 7 里是默认启用的。由于偏向锁是为了在只有一个线程执行同步块时提高性能,如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

关于 epoch

偏向锁的对象头中有一个被称为 epoch 的值,它作为偏差有效性的时间戳。

4.3 轻量级锁

轻量级锁是指当前锁是偏向锁的时候,资源被另外的线程所访问,那么偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能,下面是详细的获取过程。

轻量级锁加锁过程

  1. 紧接着上一步,如果 CAS 操作替换 ThreadID 没有获取成功,执行下一步;

  2. 如果使用 CAS 操作替换 ThreadID 失败(这时候就切换到另外一个线程的角度)说明该资源已被同步访问过,这时候就会执行锁的撤销操作,撤销偏向锁,然后等原持有偏向锁的线程到达全局安全点(SafePoint)时,会暂停原持有偏向锁的线程,然后会检查原持有偏向锁的状态,如果已经退出同步,就会唤醒持有偏向锁的线程,执行下一步;

  3. 检查对象头中的 Mark Word 记录的是否是当前线程 ID,如果是,执行同步代码,如果不是,执行偏向锁获取流程 的第 2 步。

如果用流程表示的话就是下面这样(已经包含偏向锁的获取)

4.4 重量级锁

重量级锁其实就是 synchronized 最终加锁的过程,在 JDK 1.6 之前,就是由无锁 -> 加锁的这个过程。

重量级锁的获取流程

  1. 接着上面偏向锁的获取过程,由偏向锁升级为轻量级锁,执行下一步;

  2. 会在原持有偏向锁的线程的栈中分配锁记录,将对象头中的 Mark Word 拷贝到原持有偏向锁线程的记录中,原持有偏向锁的线程获得轻量级锁,然后唤醒原持有偏向锁的线程,从安全点处继续执行,执行完毕后,执行下一步,当前线程执行第 4 步;

  3. 执行完毕后,开始轻量级解锁操作,解锁需要判断两个条件;

  • 判断对象头中的 Mark Word 中锁记录指针是否指向当前栈中记录的指针

  • 拷贝在当前线程锁记录的 Mark Word 信息是否与对象头中的 Mark Word 一致。

如果上面两个判断条件都符合的话,就进行锁释放,如果其中一个条件不符合,就会释放锁,并唤起等待的线程,进行新一轮的锁竞争。

  1. 在当前线程的栈中分配锁记录,拷贝对象头中的 MarkWord 到当前线程的锁记录中,执行 CAS 加锁操作,会把对象头 Mark Word 中锁记录指针指向当前线程锁记录,如果成功,获取轻量级锁,执行同步代码,然后执行第3步,如果不成功,执行下一步;

  2. 当前线程没有使用 CAS 成功获取锁,就会自旋一会儿,再次尝试获取,如果在多次自旋到达上限后还没有获取到锁,那么轻量级锁就会升级为 重量级锁

如果用流程图表示是这样的:

根据上面对于锁升级细致的描述,我们可以总结一下不同锁的适用范围和场景。

5. synchronized 代码块的底层实现

为了便于方便研究,我们把 synchronized 修饰代码块的示例简单化,如下代码所示:

public class SynchronizedTest {private int i;public void syncTask(){synchronized (this){i++;}}}

我们主要关注一下 synchronized 的字节码,如下所示:

从这段字节码中我们可以知道,同步语句块使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令指向同步代码块的结束位置。

那么为什么会有两个 monitorexit 呢?

请注意字节码中下方的异常表。

6. synchronized 修饰方法的底层原理

方法的同步是隐式的,也就是说 synchronized 修饰方法的底层无需使用字节码来控制,真的是这样吗?我们来反编译一波看看结果:

public class SynchronizedTest {private int i;public synchronized void syncTask(){i++;}
}

这次我们使用 javap -verbose 来输出详细的结果:

从字节码上可以看出,synchronized 修饰的方法并没有使用 monitorenter 和 monitorexit 指令,取得代之是ACC_SYNCHRONIZED 标识,该标识指明了此方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这就是 synchronized 锁在同步代码块上和同步方法上的实现差别。

热门内容:抖音服务器带宽有多大,才能供上亿人同时刷?这玩意比ThreadLocal叼多了,吓得我赶紧分享出来。 高并发下如何保证接口的幂等性别去外包国内用得最多的框架,它排第一!
最近面试BAT,整理一份面试资料《Java面试BAT通关手册》,覆盖了Java核心技术、JVM、Java并发、SSM、微服务、数据库、数据结构等等。获取方式:点“在看”,关注公众号并回复 666 领取,更多内容陆续奉上。

明天见(。・ω・。)ノ♡

synchronized 的超多干货!相关推荐

  1. horizon流程图_仪表、管道、阀门等化工工艺流程图的设计及画法,超多干货,等您来收!...

    原标题:仪表.管道.阀门等化工工艺流程图的设计及画法,超多干货,等您来收! 小编本次将为各位小伙伴们带来CAD制图.工艺流程图等画法的相关内容,欢迎各位分享学习,提高绘图技术! 设备的画法与标注 设备 ...

  2. 超详干货!Linux 环境变量配置全攻略

    点击上方"小白学视觉",选择加"星标"或"置顶" 重磅干货,第一时间送达 本文转自|机器学习实验室 Linux环境变量配置 在自定义安装软件 ...

  3. 算法到底该怎么学?算法数据结构Java编程超全干货!(ACM金牌选手分享超牛学习路径~)...

    怎么才能最快的学习算法呢?(ps:文末附2022大厂面试真题~) 这是很多知友都关心的话题,作为一个ACM金牌选手,根据我的专业角度,特给大家来分享一下! 学习算法,切记不要一上来就开始啃<算法 ...

  4. 超赞干货!2016年新鲜出炉的20款网页开发工具推荐

    越来越多的移动端和桌面端应用开始使用HTML.CSS和JS来开发了,而网页设计更是离不开这些语言.正是这种局面使得许多新的网页技术成为了可能,也催生了许多诸如React.js.Angular和Node ...

  5. 计算机视觉学习资料汇总(超多干货)

    前言 本资料首发于公众号[3D视觉工坊],原文请见计算机视觉学习资料汇总,更多干货请关注公众号后台回复关键字获取~ (一)基础操作 Linux 学习网站 Linux中国:https://linux.c ...

  6. java 多站点_Java 并发编程整体介绍 | 内含超多干货

    前段时间一直在学习多线程相关的知识,目前也算有了一个整体的认识,今天呢,主要从整体介绍一下,只谈造火箭,拧螺丝这种细节还需要自己深究. 首先是操作系统级别对于多线程的支持,由 CPU 的多级缓存.缓存 ...

  7. 如何精确设计压铸模具的溢流槽和排气槽?| 智铸超云干货分享

    压铸模具中的溢流排气系统包括溢流槽和排气槽.为了提高压铸件质量,在金属液填充型腔的过程中,应尽量排除型腔中的气体,排除混有气体和被涂料残余脱模剂.润滑剂污染的前端冷凝金属液,故而需要设置溢流-排气系统 ...

  8. 中修改环境变量_超详干货!Linux环境变量配置全攻略

    Linux环境变量配置 在自定义安装软件的时候,经常需要配置环境变量,下面列举出各种对环境变量的配置方法. 下面所有例子的环境说明如下: 系统:Ubuntu 14.0 用户名:uusama 需要配置M ...

  9. linux踩内存内存越界,Linux如何调试内存泄漏?超牛干货奉献给你(代码全)

    内存泄漏是指由于疏忽或错误造成程序未能释放已经不再使用的内存.内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存 ...

最新文章

  1. Mac下配置Maven
  2. 实施项目--为什么开发人员一直在抱怨需求变动
  3. 代码开源!激光雷达 SLAM 的闭环检测:OverlapNet
  4. 2015 Multi-University Training Contest 1 - 1002 Assignment
  5. ACM MM 2021 | 面向多模态情绪识别的双流异质图递归神经网络
  6. 去年的今天我做了些什么?
  7. 【转】ABP源码分析二十三:Authorization
  8. 官宣!张小龙史上最长演讲 4小时3万字完整版回应微信的一切
  9. 相机标定(2)opencv2实现
  10. Referenced file contains errors (http://www.springframework.org/schema/context/spring-context-3.1.xs
  11. 扫掠曲面二条引导线_说说国策下的三四线城市与会展
  12. oracle 频繁 tm tx,oracle频繁出现TX/TM锁问题
  13. FRR BGP协议分析14 -- 静态路由的处理流程
  14. This project uses AndroidX dependencies, but the ‘android.useAndroidX‘ property is not enabled
  15. 武汉大学计算机学院c语言试题,武汉大学计算机学院C语言历年试题(48页)-原创力文档...
  16. 多智能体(MARL)强化学习与博弈论
  17. 工程制图计算机绘图实训总结感悟,工程制图心得体会.doc
  18. L2CAP的基本模式(basic mode)数据格式
  19. vue项目打包出错:Unexpected token arrow «=>», expected punc «,» [static/js/chunk-1558f5a0.b64bfa00.js:626,2
  20. 通过Pyecharts绘制可视化地球竟 然如此简单

热门文章

  1. 【22,23节】Django的GET和POST属性笔记
  2. 1)头结点,头指针,
  3. join......on 后面的and 和where的区别
  4. 分享一款Markdown的css样式
  5. oracle rman异机恢复
  6. getaddrinfo()函数详解
  7. 获取应用程序路径信息
  8. 如何在Jupyter Lab中显示pyecharts的图形?
  9. Datawhale组队学习周报(第005周)
  10. 【Codeforces】1111B - Average Superhero Gang Power