大家好,我是小黑,一个在互联网苟且偷生的农民工。

之前的文章中跟大家分享了关于Java中线程的一些概念和基本的使用方法,比如如何在Java中启动一个线程,生产者消费者模式等,以及如果要保证并发情况下多线程共享数据的访问安全,操作的原子性,使用到了synchronized关键字。今天主要和大家聊一聊synchronized关键字的用法和底层的原理。

为什么要用synchronized

相信大家对于这个问题一定都有自己的答案,这里我还是要啰嗦一下,我们来看下面这段车站售票的代码:

/**
* 车站开两个窗口同时售票
*/
public class TicketDemo { public static void main(String[] args) {
TrainStation station = new TrainStation();
// 开启两个线程同时进行售票
new Thread(station, "A").start();
new Thread(station, "B").start();
}
} class TrainStation implements Runnable {
private volatile int ticket = 10;
@Override
public void run() {
while (ticket > 0) {
System.out.println("线程" + Thread.currentThread().getName() + "售出" + ticket + "号票");
ticket = ticket - 1;
}
}
}

上面这段代码是没有做考虑线程安全问题的,执行这段代码可能会出现下面的运行结果:

可以看出,两个线程都买出了10号票,这在实际业务场景中是绝对不能出现的。(你去坐火车有个大哥说你占了他的座,让你滚,还说你是票贩子,你气不气)

那因为有这种问题的存在,我们应该怎么解决呢?synchronized就是为了解决这种多线程共享数据安全问题的。

使用方式

synchronized的使用方式主要以下三种。

同步代码块

public static void main(String[] args) {
String str = "hello world";
synchronized (str) {
System.out.println(str);
}
}

同步实例方法

class TrainStation implements Runnable {
private volatile int ticket = 100; // 关键字直接写在实例方法签名上
public synchronized void sale() {
while (ticket > 0) {
System.out.println("线程" + Thread.currentThread().getName() + "售出" + ticket + "号票");
ticket = ticket - 1;
}
} @Override
public void run() {
sale();
}
}

同步静态方法

class TrainStation implements Runnable {
// 注意这里ticket变量声明为static的,因为静态方法只能访问静态变量
private volatile static int ticket = 100; // 也可以直接放在静态方法的签名上
public static synchronized void sale() {
while (ticket > 0) {
System.out.println("线程" + Thread.currentThread().getName() + "售出" + ticket + "号票");
ticket = ticket - 1;
}
}
@Override
public void run() {
sale();
}
}

字节码语义

通过程序运行,我们发现通过synchronized关键字确实可以保证线程安全,那计算机到底是怎么保证的呢?这个关键字背后到底做了些什么?我们可以看一下java代码编译后的class文件。首先来看同步代码块编译后的class。通过javap -v 名称可以查看字节码文件:

 public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: ldc #2 // String hello world
2: astore_1
3: aload_1
4: dup
5: astore_2
6: monitorenter // 监视器进入
7: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
10: aload_1
11: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
14: aload_2
15: monitorexit // 监视器退出
16: goto 24
19: astore_3
20: aload_2
21: monitorexit
22: aload_3
23: athrow
24: return

注意看第6行和第15行,这两个指令是增加synchronized代码块之后才会出现的,monitor是一个对象的监视器,monitorenter代表这段指令的执行要先拿到对象的监视器之后,才能接着往下执行,而monitorexit代表执行完synchronized代码块之后要从对象监视器中退出,也就是要释放。所以这个对象监视器也就是我们所说的锁,获取锁就是获取这个对象监视器的所有权。

接下来我们在看看synchronized修饰实例方法时的字节码文件是什么样的。

 public synchronized void sale();
descriptor: ()V
//方法标识ACC_PUBLIC代表public修饰,ACC_SYNCHRONIZED指明该方法为同步方法
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field ticket:I
// 省略其他无关字节码

可以看到synchronized修饰实例方法上之后不会再有monitorentermonitorexit指令,而是直接在这个方法上增加一个ACC_SYNCHRONIZED的flag。当程序在运行时,调用sale()方法时,会检查该方法是否有ACC_SYNCHRONIZED访问标识,如果有,则表明该方法是同步方法,这时候还行线程会先尝试去获取该方法对应的监视器(monitor)对象,如果获取成功,则继续执行该sale()方法,在执行期间,任何其他线程都不能再获取该方法监视器的使用权,知道该方法执行完毕或者抛出异常,才会释放,其他线程可以重新获得该监视器。

那么synchronized修饰静态方法的字节码文件是什么样呢?

public static synchronized void sale();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=0, args_size=0
0: getstatic #2 // Field ticket:I
// 省略其他无关字节码

可以看出synchronized修饰静态方法和实例方法没有区别,都是增加一个ACC_SYNCHRONIZED的flag,静态方法只是比实例方法多一个ACC_STATIC标识代表这个方法是静态的。

以上的同步代码块,同步方法中都提到对象监视器这个概念,那么三种同步方式使用的对象监视器具体是哪个对象呢?

同步代码块的对象监视器就是使用的我们synchronized(str)中的str,也就是我们括号中指定的对象。而我们在开发中增加同步代码块的目的是为了多个线程同一时间只能有一个线程持有监视器,所以这个对象的指定一定要是多个线程共享的对象,不能直接在括号中new一个对象,这样不能做到互斥,也就不能保证安全。

同步实例方法的对象监视器是当前这个实例,也就是this。

同步静态方法的对象监视器是当前这个静态方法所在类的Class对象,我们都知道Java中每个类在运行过程中也会用一个对象表示,就是这个类的对象,每个类有且仅有一个。

对象锁(monitor)

上面说了线程要进入同步代码块需要先获取到对象监视器,也就是对象锁,那在开始说之前我们先来了解下在Java中一个对象都由哪些东西组成。

这里先问大家一个问题,Object obj = new Object()这段代码在JVM中是怎样的一个内存分布?

想必了解过JVM知识的同学应该都知道,new Object()会在堆内存中创建一个对象,Object obj是栈内存中的一个引用,这个引用指向堆中的对象。那么怎么知道堆内存中的对象到底由哪些内容组成呢?这里给大家介绍一个工具叫JOL(Java Object Layout)Java对象布局。可以通过maven在项目中直接引入。

<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>

引入之后在代码中可以打印出对象的内存分布。

public static void main(String[] args) {
Object obj = new Object();
// parseInstance将对象解析,toPrintable让解析后的结果可输出
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}

输出后的结果如下:

java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

从结果上可以看出,这个obj对象主要分4部分,每部分的SIZE=4代表4个字节,前三行是对象头object header,最后一行的4个字节是为了保证一个对象的大小能是8的整数倍。

我们再来看看对于一个加了锁的对象,打印出来有什么不一样?

public static void main(String[] args) {
Object obj = new Object();
synchronized (obj){
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 58 f7 19 01 (01011000 11110111 00011001 00000001) (18478936)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

可以很明显的看到,最前面的8个字节发生了变化,也就是Mark Word变了。所以给对象加锁,实际就是改变对象的Mark Word。

Mark Word中的这8个字节具有不同的含义,为了让这64个bit能表示更多信息,JVM将最后2位设置为标记位,不同标记位下的Mark word含义如下:

|------------------------------------------------------------------------------|--------------------|
| Mark Word (64 bits) | State |
|------------------------------------------------------------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | 无锁态 |
|------------------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | 偏向锁 |
|------------------------------------------------------------------------------|--------------------|
| ptr_to_lock_record:62 | lock:2 | 轻量级锁 |
|------------------------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | lock:2 | 重量级锁 |
|------------------------------------------------------------------------------|--------------------|
| | lock:2 | GC标记 |
|------------------------------------------------------------------------------|--------------------|

其中最后两位的锁标记位,不同值代表不同含义。

biased_lock lock 状态
0 00 无锁态(NEW)
0 01 偏向锁
1 01 偏向锁
0 00 轻量级锁
0 10 重量级锁
0 11 GC标记

biased_lock标记该对象是否启用偏向锁,1代表启用偏向锁,0代表未启用。

age:4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。

identity_hashcode:25位的对象标识Hash码,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象被锁定时,该值会移动到管程Monitor中。

thread:持有偏向锁的线程ID。

epoch:偏向时间戳。

ptr_to_lock_record:指向栈中锁记录的指针。

ptr_to_heavyweight_monitor:指向管程Monitor的指针。

锁升级过程

既然会有无锁,偏向锁,轻量级锁,重量级锁,那么这些锁是怎么样一个升级过程呢,我们来看一下。

新建

从前面讲到对象头的结构和我们上面打印出来的对象内存分布,可以看出新创建的一个对象,它的标记位是00,偏向锁标记(biased_lock)也是0,表示该对象是无锁态。

偏向锁

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

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

轻量级锁

轻量级锁是指当锁是偏向锁的时候,有其他线程来竞争,但是该锁正在被其他线程访问,那么就会升级为轻量级锁。或者还有一种情况就是关闭JVM的偏向锁开关,那么一开始锁对象就会被标记位轻量级锁。

轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。

在进入同步代码时,如果对象锁状态符合升级轻量级锁的条件,虚拟机会在当前想要竞争锁的线程的栈帧中开辟一个Lock Record空间,并将锁对象的Mark Word拷贝到Lock Record空间中。

然后虚拟机会使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record中的owner指针指向对象的Mark Word。

如果操作成功,则表示当前线程获得锁,如果失败则表示其他线程持有该锁,当前线程会尝试使用自旋的方式来重新获取。

轻量级锁解锁时,会使用CAS操作将Lock Record替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

重量级锁

重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。是依赖于底层操作系统的Mutex实现,Mutex也叫互斥锁。也就是说重量级锁会让锁从用户态切换到内核态,将线程的调度交给操作系统,性能相比会很低。

整个锁升级的过程通过下面这张图能更全面的展示。

有需要原图的朋友,关注我的公众号【黑子的学习笔记】后台回复“锁升级”获取。


好的,今天的内容就到这里,我们下期再见。

并发编程之:synchronized的更多相关文章

  1. Java并发编程之synchronized关键字

    整理一下synchronized关键字相关的知识点. 在多线程并发编程中synchronized扮演着相当重要的角色,synchronized关键字是用来控制线程同步的,可以保证在同一个时刻,只有一个 ...

  2. 高并发编程之synchronized

    一.什么是线程? 线程,有时被称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元.一个标准的线程由线程ID,当前指令指针(PC),寄存器集合和堆栈组成.另外,线程 ...

  3. 并发编程之synchronized锁(一)

    一.设计同步器的意义 多线程编程中,有可能会出现多个线程同时访问同一个共享.可变资源的情况,这个资源我们称之其为临界资源:这种资源可能是:对象.变量.文件等. 共享:资源可以由多个线程同时访问 可变: ...

  4. Java并发编程之synchronized

    在Java编程中,为了保证线程安全,有3种不同的思路1.互斥同步:包括synchronized和lock等. 2.非阻塞同步:如AtomicInteger的increaseAndGet()方法等. 3 ...

  5. Java 多线程并发编程之 Synchronized 关键字

    synchronized 关键字解析 同步锁依赖于对象,每个对象都有一个同步锁. 现有一成员变量 Test,当线程 A 调用 Test 的 synchronized 方法,线程 A 获得 Test 的 ...

  6. 并发编程之synchronized关键字

    synchronized关键字 synchronized关键字最主要的三种使用方式的总结 1.修饰实例方法,作用于当前对象实例加锁,进入同步代码块前要获得当前对象实例的锁 2.修饰静态方法,作用于当前 ...

  7. 并发编程之synchronized(二)------jvm对synchronized的优化

    一.锁的粗化 看如下代码 public class Test { StringBuffer stb = new StringBuffer(); public void test1(){ //jvm的优 ...

  8. Java并发编程之CAS

    CAS(Compare and swap)比较和替换是设计并发算法时用到的一种技术.简单来说,比较和替换是使用一个期望值和一个变量的当前值进行比较,如果当前变量的值与我们期望的值相等,就使用一个新值替 ...

  9. 并发编程之wait()、notify()

    前面的并发编程之volatile中我们用程序模拟了一个场景:在main方法中开启两个线程,其中一个线程t1往list里循环添加元素,另一个线程t2监听list中的size,当size等于5时,t2线程 ...

  10. 并发编程之J.U.C的第二篇

    并发编程之J.U.C的第二篇 3.2 StampedLock 4. Semaphore Semaphore原理 5. CountdownLatch 6. CyclicBarrier 7.线程安全集合类 ...

随机推荐

  1. 网络损伤仪WANsim的功能与型号

    网络损伤仪简介 WANsim网络损伤仪是一款可以模拟真实的广域网状况的设备.它可以通过简单的数据填写模拟出时延.带宽抖动.丢包.乱序.误码.报文重复等网络状况. 大多数公司在进行产品测试时,只关注了应 ...

  2. Nodejs 处理异步(获取异步数据并处理)的方法

    方法1. 回调函数方式 将异步方法如readFile封装到一个自定义函数中,通过将异步方法得到的结果传给自定义方法的回调函数参数.具体如下(以fs模块的readFile方法为例): //封装 var ...

  3. java学习笔记之OOP(二)

    java学习笔记二.面向对象[OOP]Object Oriented Programming 一.三大特性: 1.封装:隐藏对象的属性和实现细节,仅对外提供公共访问方式,将变化隔离,便于使用,提高复用 ...

  4. python3中文件/IO编程

    python3的文件操作可谓是我见过所有语言中最舒服的,那我们来一起看一下py3中的文件操作. 1:文件的打开方式有以下几种: 注:以上图表参考菜鸟教程 2:定位读写文件  f = open(&quo ...

  5. SpringBoot时代背景

    微服务 James Lewis Martin Fowler 2014年提出微服务完整概念,https://martinfowler.com/microservices/ In short, the m ...

  6. AT指令集及其通信测试方式

    1 前言 AT即Attention,AT指令集是从终端设备(Terminal Equipment,TE)向终端适配器(Terminal Adapter, TA)发送的.通过TA,TE发送AT指令来控制 ...

  7. NOIP&CSP PJ 难度刷题记录

    前言 本来不想写前言的(>人<:) 这只是 mjl 给我们布置的作业,并不是我自己在刷题! 不保证所有代码的正确性,它们仅仅是通过了所有数据点而已. 1.模拟板块 整体难度:红~黄(模拟不 ...

  8. scrapy 错误:Missing scheme in request url: %s' % self._url

    先说报错原因:使用了和start_urls同名的参数 我通过scral crawl projename -a start_urls=http:example.com来传start_urls,然后想在项 ...

  9. 20分钟掌握Android Gradle

    目前国内对Android领域的探索已经越来越深,不少技术领域如插件化.热修复.构建系统等都对Gradle有迫切的需求,不懂Gradle将无法完成上述事情.所以Gradle必须要学习. Gradle 里 ...

  10. MySQL-14-备份恢复

    备份类型 1 热备 在业务正常时,对数据库进行备份数据,并且能够一致性恢复(只能是innodb) 对业务影响非常小 2 温备 锁表备份,只能查询不能修改(myisam) 影响到写入操作 3 冷备 关闭 ...