文档章节

Part 002: Java并发机制的底层实现原理

liululee
 liululee
发布于 2019/02/14 14:41
字数 3640
阅读 30
收藏 2

1.volatile实现原理

Java语言规范第3版中对volatile的定义如下:

  • Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。

1.1 volatile特性

具有可见性、有序性,不具备原子性。

1.2 volatile适用场景

1.适用于对变量的写操作不依赖于当前值,对变量的读取操作不依赖于非volatile变量。 2.适用于读多写少的场景。

1.3 volatile是如何保证可见性呢?

如果一个变量被声明为volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。

volatile关键字修饰的共享变量进行写操作时在汇编代码中会多出lock行代码,通过查IA-32架构软件开发者手册可知,lock前缀的指令在多核处理器下会引发两件事: a.将当前处理器缓存行的数据写回到系统内存。b.这个写回内存的操作会使其他CPU缓存了该内存地址的数据无效。 volatile两条实现原则:

  • a.Lock前缀指令会引起处理器缓存回写到内存。

LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存,也就是“锁总线”。目前较新的处理器中,LOCK#信号一般是“锁缓存”(如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反,它会锁定这个内存区域并回写到内存,并使用缓存一致性机制来确保修改操作的原子性。此操作被称为:缓存锁定),因为锁总线的开销比较大(锁住总线,导致其他CPU无法访问总线,不能访问总线就意味着不能访问系统内存)。

  • b.一个处理器的缓存回写到内存,会导致其他处理器的缓存失效。

处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理的缓存数据在总线上保持一致。

2.synchronized实现原理

synchronized被很多人称之为重量锁,经过Java SE 1.6优化后,有些情况下它已经不那么重了。synchronized实现同步的基础是:Java中每个对象都可以作为锁。具体表现为:

  • 对于普通同步方法,锁是当前实例的对象。
  • 对于静态同步方法,锁是当前类的Class对象。
  • 对于同步方法块,锁是Synchronized括号里配置的对象。

当一个线程试图访问同步代码块时,必须首先获得锁,退出或异常时释放锁,那么锁到底在哪里?里面存储的信息有哪些呢?

JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,代码块同步是使用monitorenter和monitorexit指令来实现的,而方法同步是使用另一种方式实现,在JVM中并没有详细说明,但方法同步同样可以使用这两个指令来实现。

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何一个对象都有一个monitor与之关联,当一个monitor被持有后,它便处于锁定状态。线程执行到monitorenter指令时,会尝试获取该对象对应的monitor的所有权,即尝试获取该对象的锁。

2.1 Java对象头

synchronized用的锁是存在Java对象头里的。如对象为数组类型,则虚拟机使用3个字宽(Word)来存储对象头(比非数组类型多存储了一个数组长度),非数组类型使用2个字宽存储对象头。 |长度|内容|说明| |----|-----|------| |32/64bit | Mark Word| 存储对象的hashCode或锁信息| |32/64bit|Class Metadata Address|存储到对象类型数据的指针| |32/64bit|Array Length|数组的长度(如果对象为数组类型)|

Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。

32位虚拟机默认存储结构 |锁状态|25bit|4bit|1bit是否偏向锁|2bit 锁标志位| |-----|-----|------|------|------| |无锁状态|对象的hashCode|对象分代年龄|0|01| 运行期间,Mark Word存储的数据会随着锁标志位的变化而变化。 64位虚拟机默认存储结构 |锁状态|25bit|31bit|1bit cms_free|4bit 分代年龄|1bit 是否偏向锁|2bit 锁标志位| |-----|-----|------|------|------|------|------| |无锁状态|unused|hashCode|||0|01| |偏向锁|ThreadID(54bit)Epoch(2bit)|||1|01|

2.2 锁的升级和对比

锁一共四种状态:无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态。 锁只能升级而不能降级,这么做的目的是为了提高获得锁和释放锁的效率。

2.2.1 偏向锁

HotSpot作者研究发现:锁不仅存在多线程竞争,而且总是有同一线程多次获得。为了让线程获得锁的代价更低而引入了偏向锁。

具体步骤:

  • 1.当某一线程访问同步块并获取锁时,会在对象头和栈帧中记录存储锁偏向的线程ID
  • 2.如果该线程再次进入和退出同步块时,不需要进行CAS操作来加锁和解锁,而只需要判断下对象头中的Mark Word里是否存储着当前线程的偏向锁。如果Mark Word中存在指向该线程的偏向锁,则表示该线程已经获得了锁。
  • 3.如果Mark Word不存在指向该线程的偏向锁,则判断该Mark Word中偏向锁标识是否设置为1(表示当前为偏向锁)。如果没有设置,则使用CAS竞争锁。如果已经设置,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁采用一种等到竞争才会释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等到全局安全点(在这个时间点上没有正在执行的字节码)

2.2.2 轻量级锁

a.轻量级锁加锁

线程执行同步块时,JVM会现在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中(官方称之为: Displaced Mark Word)。 然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。若成功,则线程获得锁,若失败,表示其他线程竞争锁,当前线程尝试使用自旋获取锁。

b.轻量级锁解锁

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

因为自旋会消耗CPU,为了避免无用的自旋,一旦线程升级为重量级锁,就不能再恢复到轻量级锁状态。当锁处于该状态下时,其他线程试图获取锁时,都会被阻塞,只有当持有该锁的线程释放锁后,才会唤醒这些线程。被唤醒的线程进行新一轮的“九子夺嫡”夺锁之争。

2.2.3 锁优缺点对比

优点 缺点 适用场景
偏向锁 加锁解锁不需额外消耗,和执行非同步方法相比仅存在纳秒级的差距 线程间存在锁竞争,会带来额外锁撤销消耗 适用一个线程访问同步块场景
轻量级锁 竞争的线程不会阻塞,提高了程序相应速度 如果始终得不到锁竞争的线程,使用自旋会消耗CPU 追求响应时间,同步块执行速度非常快
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量,同步块执行速度较长

3.原子操作的实现原理

原子操作意为“不可中断的一个或一系列操作”。在多处理器上实现原子操作就变得有点复杂。

3.1 处理器如何实现原子操作

处理器基于对缓存加锁或总线加锁的方式来保证操作的原子性,当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。较新的处理器能保证单处理器对同一个缓存行进行16/32/64位操作是原子的,而对于较为复杂的内存操作,处理器是不能自动保证其操作的原子性的。比如,跨总线宽度,跨多个缓存行和跨页表访问。但处理器提供了总线锁和缓存锁这两个机制来保证复杂内存操作的原子性。

3.1.1 使用总线锁保证原子性

如果多个处理器同事对共享变量就行读改写操作(i++),那么共享变量就会被多个处理器同时操作,这样读改写的操作就不是原子的,操作完后共享变量的值会和期望值不一致。原因是多个处理器同时出各自的缓存中读取变量,然后分别进行+1操作,再分别写入系统内存中。 而总线锁就是使用处理器提供的LOCK#信号,当一个处理器在总线上输出该信号时,其他处理器的请求将被阻塞,那么该处理器就可以独占内存。

3.1.2 使用缓存锁保证原子性

缓存锁定:内存区域如果被缓存在处理器的缓存行中,并且在LOCK操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会组织同时修改由两个以上处理器缓存的内存区域数据。当其他处理器回写已被锁定的缓存行时,会使缓存行无效。

3.1.3 不使用缓存锁定的场景

1.操作数据不能被缓存或跨多个缓存行。此时会调用总线锁。 2.部分处理器不支持缓存锁定。如Pentium处理器和Intel 486。

3.2 Java如何实现原子操作

在Java中可以通过锁和循环CAS的方式来实现原子操作。

3.2.1 使用循环CAS实现原子操作

JVM中的CAS操作利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止。

从Java1.5开始,JDK的并发包提供了一些类来支持原子操作,如AtomicLong,AtomicBoolean,AtomicInteger。还提供了一些原子的自增1和自减1方法。

CAS原子操作的三大问题:

  • 1.ABA问题

CAS操作值时,要检查值有没有发生变化,如果没有发生变化则更新,但如果变量原始值为A,变成了B,又变成了A。那么使用CAS检查时,会认为该值没有发生变化,但实际上却发生变化了。解决办法:在变量更新时增加版本号,更改一次版本号加1。Java1.5开始,新增了一个AtomicStampedReference来解决ABA问题。该类中的compareAndSet方法就是首先检查当前引用是否等于预期引用,然后检查当前标志是否等于预期标志,若相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

  • 2.循环时间长开销大

自旋CAS长时间不成功,会给CPU带来非常大的执行开销。若JVM支持处理器的pause指令,则效率会有一定提升。pause指令作用:a.延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源。b.避免退出循环时,因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。

  • 3.只能保证一个共享变量的原子操作

解决方法:(1)使用锁。(2)把多个共享变量合并成一个共享变量来操作,比如i=2,j=a,合并为ij=2a。(3)从java1.5开始,JDK提供了AtomicReference类,可以将多个变量放在一个对象中来实现原子操作。

3.2.2 使用锁实现原子操作

JVM内部的锁有偏向锁,轻量级锁,互斥锁。但除了偏向锁,JVM实现锁的方式都运用了循环CAS,线程进入和退出同步块时都使用CAS来获取锁和释放锁。

4.动手操作

本节使用代码验证volatile和synchronized关键字的使用。 1.验证volatile具有可见性,不具备原子性。


	public volatile int ivl = 0;

	public static void main(String[] args) {
		final App app = new App();
		for (int i = 0; i < 20; i++) {
			new Thread() {
				@Override
				public void run() {
					for (int j = 0; j < 10000; j++) {
						app.doSome();
					}

				}
			}.start();
		}
		while (Thread.activeCount() > 1) { // 保证前面的线程都执行完成
			Thread.yield();
		}
		System.out.println(app.ivl);

	}

	/**
	 * 自增方法
	 * 
	 * @return
	 */
	public int doSome() {
		return ivl++;
	}
}

如果执行这段代码,会发现,每次输出的值都是不一样的。而且始终都是小于20*10000的值。自增操作是不具备原子性的,具体原因上面已经分析过。 如果要使得到的结果等于20*10000,可以使用synchronized修饰doSome()方法,这样在处理器读取内存时加LOCK#指令,保证内存只能被一个处理器访问和修改。

© 著作权归作者所有

liululee
粉丝 130
博文 80
码字总数 117498
作品 0
杭州
程序员
私信 提问
读书笔记之《Java并发编程的艺术》-并发编程基础

读书笔记部分内容来源书出版书,版权归本书作者,如有错误,请指正。 欢迎star、fork,读书笔记系列会同步更新 git https://github.com/xuminwlt/j360-jdk module j360-jdk-thread/me.j360....

Hi徐敏
2015/11/11
4K
8
BAT最新Java面试题汇总:并发编程+JVM+Spring+分布式+缓存等!

前言 作为一个开发人员,你是否面上了自己理想的公司,薪资达到心中理想的高度? 面试:如果不准备充分的面试,完全是浪费时间,更是对自己的不负责。 今天给大家分享下我整理的Java架构面试...

别打我会飞
2019/06/03
328
0
BAT等公司必问的8道Java经典面试题,你都会了吗?

工作多年以及在面试中,我经常能体会到,有些面试者确实是认真努力工作,但坦白说表现出的能力水平却不足以通过面试,通常是两方面原因: 1、“知其然不知其所以然”。做了多年技术,开发了很...

java填坑路
2019/01/06
0
0
牛逼哄哄的Dubbo框架,底层到底是什么原理?

搞了N年Java,仍有不少朋友困惑:用了很多年Dubbo,觉得自己挺厉害,跳槽面试时一问RPC,一问底层通讯,一问NIO和AIO,就一脸懵逼,到底该怎么办? (大家有没有这样的感触?Dubbo用得很熟,...

Java猫
2019/03/27
0
0
跳槽时,这些Java面试题99%会被问到

我在 Oracle 已经工作了近 7 年,面试过从初级到非常资深的Java工程师,且由于 Java 组工作任务的特点,我非常注重面试者的计算机科学基础和编程语言的理解深度,可以不要求面试者非要精通 ...

Java小铺
2018/08/15
517
0

没有更多内容

加载失败,请刷新页面

加载更多

Android MVP 快速开发框架ZBLibrary

MVP 架构,提供一套开发标准(View,Data,Event)以及模板和工具类并规范代码。封装层级少,简单高效兼容性好。 OKHttp、UIL图片加载、ZXing二维码、沉浸状态栏、下载安装、自动缓存以及各种B...

boonya
9分钟前
15
0
printf的格式很长的论点是什么?

printf函数采用参数类型,例如%d或%i用于signed int 。 但是,我没有看到任何long价值的东西。 #1楼 如果您打算像我一样打印unsigned long long ,请使用: unsigned long long n;printf("...

技术盛宴
15分钟前
19
0
为BlueLake主题增加图片放大效果

fancyBox 是一个流行的媒体展示增强组件,可以方便为网站添加图片放大、相册浏览、视频弹出层播放等效果。优点有使用简单,支持高度自定义,兼顾触屏、响应式移动端特性,总之使用体验相当好...

CREATE_17
16分钟前
21
0
如何将现有的Git存储库导入另一个?

我在名为XXX的文件夹中有一个Git存储库,还有第二个名为YYY的 Git存储库。 我想将XXX存储库作为名为ZZZ的子目录导入YYY存储库,并将所有XXX的更改历史记录添加到YYY 。 之前的文件夹结构: ...

javail
30分钟前
10
0
JSP-Servlet入门2之JSP运行原理(一)

JSP全名为Java Server Pages,中文名叫java服务器页面,是一种动态页面技术 。实际上JSP是指在HTML中嵌入java脚本语言, 一、 JSP起源 在很多动态网页中,绝大部分内容都是固定不变的,只有局...

橘子_
58分钟前
12
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部