Java并发编程的艺术——Volatile和synchronized的实现

2019年10月17日:学习完《并发编程》的第二章之后,有下面的总结:

一、对已经熟悉的内容的总结:

1、Java代码的执行过程:Java代码先经过编译编程Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终需要转化为汇编指令,然后在CPU上执行。

2、Volatile介绍:volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性指:当一个线程修改一个共享变量时,另外一个线程能读到这个变量在修改了之后的值。

3、Volatile定义:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。在Java中,如果一个变量被声明为volatile,那么Java线程的内部模型会去确保所有线程看到这个变量的值都是一致的。

4、Volatile实现原理:声明了volatile的变量,在进行写操作的时候,会多出一行以lock为前缀的汇编指令,而lock前缀指令在多核处理器下会引发:①将当前处理器缓存行的数据写回到系统内存;②这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。这样,当其他处理器对同一变量进行操作时,需要重新从系统内存中去读取值,再重新放到处理器的缓存中,从而保证了变量的可见性。

5、Volatile使用优化:方法:将共享变量追加到64字节。理由:有很多处理器的高速缓存行都是64字节宽的,并且不支持部分填充缓存行。如果单个共享变量不足64字节,会将其他的共享变量一起放到同一个缓存行中,那么如果此时采用缓存锁定(区别于总线锁定)的方式,就会将整个缓存行都锁定,从而导致其他处理器也不能去访问自己的共享变量了,从而严重影响多处理器下的性能。而追加到64字节后,每个共享变量独享一个缓存行,就不会被其他的缓存锁定影响了。

6、并非所有情况都要追加到64字节:①处理器的高速缓存行不是64字节,例如32字节时;②共享变量的写操作没有那么频繁,也就是缓存锁定没那么频繁的时候。

7、synchronized的实现原理:synchronized被称为重量级锁。synchronized实现同步的基础:Java中的每一个对象都可以作为锁。

具体表现形式:

  1. 对于普通同步方法,锁是当前实例对象;
  2. 对于静态同步方法,锁是当前类的Class对象;
  3. 对于同步代码块,锁是synchronized括号里配置的对象。

           首先,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但是两者的实现细节不一样。其中,代码块同步是使用monitorenter和monitorexit两条指令实现,将monitorenter指令插入到同步代码块的开始位置,将monitorexit指令插入到同步代码块的结束位置和异常处,JVM通过保证每个monitorenter指令都有一个monitorexit指令与之一一对应,来实现代码块的同步;而同步方法也可以用这两个指令来实现。

           然后,任何对象都有一个monitor与之关联,当且仅当有一个monitor被持有之后,它处于锁定状态。而线程执行到monitorenter指令,将会尝试获取对象对应的monitor的所有权,这就是线程试图获得对象的锁。

           其他解释:

           同步块:当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

           方法:synchronized(其修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 标识来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

8、Java对象头

补充知识:JAVA对象 = 对象头 + 实例数据 + 对齐填充。

           在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header),实例数据(Instance Data)和对齐填充(Padding)。

           对象头由两部分组成,一部分用于存储自身的运行时数据,称之为 Mark Word,另外一部分是类型指针 Class Metadata Address,及对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的示例。另外,如果是对象是一个数组,对象头中还有一个部分是保存数组长度值的,称为Array Length。

           实例数据是对象真正存储的有效信息,也是程序代码中所定义的各种类型的字段内容。无论是从父类继承的,还是子类自己定义的,都需要记录在此。

           对齐填充仅仅是起到占位符的作用,因为JVM的自动内存管理系统要求对象的其实地址必须是8字节的整数倍,而对象头部分刚刚好是8字节(32bit)的整数倍,所以加上实例数据之后,需要填充来保证对象头的8字节对齐。公式:(对象头 + 实例数据 + padding) % 8等于0且0 <= padding < 8

           synchronized用的对象锁就是存在Java对象头里的。

           如果对象是数组类型,则虚拟机用3个字节宽度去存储对象头,如果是非数组类型,就只用2个字节款度去存储。Java头对象由Mark Word(存放对象的hashCode或者锁信息等)、Class Metadata Address(存储到对象类型数据的指针)、Array Length(如果是数组对象的话,这里存放数组的长度值)三个部分组成:

9、锁一共有4种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,它们的级别是从低到高的。锁可以升级但是不能降级,目的是为了提高获得锁和释放锁的效率。

9.1.1 偏向锁介绍:大多情况下,其实锁并不是被多个线程竞争,而是由同一个线程多次获取。为了降低多次获取锁、释放锁的开销,引入了偏向锁。当一个线程访问同步块并获取了锁之后,会在对象头和栈帧(其中保存着方法的执行信息)的锁记录中存储这个锁偏向的线程的ID。以后,该线程在进入和退出同步块时,不需要进行CAS操作来加锁或者解锁,只需要测试一些对象头的Mark Word中是否存储着指向该线程的偏向锁。

  • 如果测试成功,表示线程已经获得了该锁;
  • 如果测试失败,就在测试一下Mark Word中的偏向锁的标识是否设置为1(表示当前是否是偏向锁)。
  1.    如果没有设置(表示不是偏向锁了),则使用CAS锁竞争机制来竞争锁;
  2. 如果已经设置,则尝试使用CAS将对象头的偏向锁指向当前线程(其实就是修改Mark Word中的线程ID)。

9.1.2 偏向锁的撤销:(这里的撤销指申请偏向锁不成功,导致将锁对象改为非偏向锁状态。并不是退出同步块之后的释放锁。)

         偏向锁使用了一种等到竞争出现才会去释放锁的机制。并且偏向锁的撤销,需要等到全局安全点(此时没有正在执行的字节码,这是产生耗费的主要原因。)

  • 它会首先暂停拥有偏向锁的线程(下图的线程 1),然后检查持有偏向锁的线程是否活着;
    • 如果线程不处于活动状态,则将对象头设置为无锁状态(下图示例);
    • 如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

 9.1.3 关闭偏向锁:在应用程序启动几秒钟之后,会默认开启偏向锁。可以使用 -XX:-UsebiasedLocking-false关闭偏向锁,那么程序默认进入轻量级锁。

         9.1.4 适用场景:只有一个线 程不断访问同步块的时候。

         9.2.1 轻量级锁:

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

         自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。 

         解锁:使用原子的CAS操作将 Displaced Mark Word 替换回到对象头。如果成功了,说明没有竞争发生,如果失败, 表示当前锁存在竞争(因为有其他竞争存在,导致其他线程自旋一直进行,使锁升级为了重量级的锁,从而CAS操作也就失败了,而那些争夺锁的线程也因为锁的升级被阻塞了)。这时候,占有锁的线程要释放锁,并且唤醒那些因为自旋最终阻塞的线程,然后重新竞争锁和访问同步块。

9.2.2 锁的优缺点对比:

10、原子操作的实现原理

10.1 原子操作介绍:不可被打断的一个或者一系列操作。要么都不做,要么都做。

        (概念介绍)假共享:多个CPU同时修改同一个缓存行的不同部分,而引起其中一个CPU的操作无效。

        内存顺序冲突:由假共享导致的现象称为内存顺序冲突,当出现的时候,代表出现了数据冒险,CPU必须清空流水线。

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

①对于基本的内存操作:处理器能自动保证从系统内存中读取或者写入一个字节是原子的。其实就是保证了Load指令和Store指令的原子性,当一个处理器读取一个 字节时,其他处理器不能访问这个字节的内存地址。

②对于复杂的内存操作,比如:跨总线宽度、跨多个缓存行和跨页表的访问:处理器是不能自动保证其原子性的。但是处理器提供了总线锁定和缓存锁定来保证复杂内存操作的原子性。

两个处理器对共享变量 i 的操作

        例子:CPU1:访问到i = 1,执行i++,得到结果 i = 2;CPU2:访问到i = 1,执行i++,得到结果 i = ?

        如果是多个处理器同时对共享资源进行读改写操作(就是先去访问变量的值,再进行写操作),那么这样的操作就不是原子操作了。预期上面的例子中的i应该是3,然而可能在CPU1写i的值之前,CPU2就已经读走了i为1的值,最后i的值是3了,这不符合我们的预期。

        总线锁定:使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,导致上例中的CPU2既不能写、也不能读共享变量i的值,那么CPU1就可以独占共享内存。

        缺点:总线锁定将CPU和内存之间的通信都锁住了,是的其他处理器与它们自己单独操作的内存之间也被阻断了,而因此不能操作内存地址的数据了。从而使得总线锁定的开销、浪费很大。

        缓存锁定:内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不再像总线锁定那样在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性。(这里的缓存一致性机制会阻止同时修改由两个或者以上的处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行时,会使缓存行无效。)也就是说上面的例子中,CPU2缓存的行会变为无效的,当它需要写回的时候,要重新缓存。

        缺点:有两种情况不能使用缓存锁定。①操作的数据不能被缓存到缓存行,或者其缓存需要占用多个缓存行时;②有些处理器不支持缓存锁定。就只有调用总线锁定了。

10.3 Java如何实现原子操作:锁和循环CAS

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

        具体调用CAS操作的例子,见下面的代码:

/**
 * @author: lei
 * @data: 2019.10.18 15:43
 * @description: 实现了一个基于CAS线程安全的计数器和一个非线程安全的计数器
 */
public class Counter {
	private AtomicInteger atomic = new AtomicInteger(0);
	private int i = 0;
	
	public static void main(String[] args) throws InterruptedException {
		final Counter cas = new Counter();
		
		List<Thread> ts = new ArrayList<>(600);   //创建线程列表
		long start = System.currentTimeMillis();
		//创建100个线程,并放到线程列表中去
		for(int j = 0;j < 100;j++){
			Thread t = new Thread(new Runnable() {
				//线程中要做的事情
				@Override
				public void run() {
					for(int i = 0;i < 10000;i++){
						cas.safeCount();  //一次安全的计数
						cas.count();      //一次不安全的计数
					}
				}
			});
			ts.add(t);  //将新创建的线程加入到线程列表中去
		}
		
		for(Thread t: ts) {
			t.start();        //逐个启动那100个线程
		}
		
		for(Thread t: ts){
			t.join();          //等待所有线程t都执行完成
		}
		
		System.out.println("cas.i: " + cas.i);
		System.out.println("atomic.i: " + cas.atomic.get());
		System.out.println(System.currentTimeMillis() - start);
	}
	
	//非线程安全的计数器
	private void count(){
		i++;
	}
	
	//线程安全的计数器
	private void safeCount(){
		for(;;){
			int i = atomic.get();
			boolean suc = atomic.compareAndSet(i, ++i);   //调用cas操作,比对旧值i,比对成功则放入新值 ++i,并返回true
			//只有成功时,才退出循环,结束;不然就一直自旋等待
			if(suc)
				break;
		}
	}
}//class end

        ② CAS实现原子操作的三大问题

        CAS操作虽然很高效地解决了原子操作,但是存在三个问题:ABA问题、循环时间长开销大、只能保证一个共享变量的原子操作。

        ABA问题:如果一个变量的值原来是A,但是经过别的线程的修改变成了B,又变成了A,那么CAS操作在进行旧值检查的时候,会认为这个变量的值没有发现改变,但实际上是有过变化的。解决思路:在变量的前面加上一个版本号。改进后的CAS操作会首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志(这里就是在检查版本标识了)。只有上述检查全部相等之后,才会将引用和标志修改为给定的更新值。

public boolean compareAndSet(
    V      expectedReference,  //预期引用
    V      newReference,       //更新后的引用
    int    expectedStamp,      //预期标志
    int    newStamp            //更新后的标志
) 

        循环时间长导致开销大:因为是采用自旋CAS在等待锁,如果一直不成功,会给CPU带来非常大的开销。

        只能保证一个共享变量的原子操作:对多个共享变量进行操作的时候,循环CAS无法保证操作的原子性,因为我们只会在CAS操作时比对一个值。解决思路:采用锁,还可以将多个共享变量合成一个变量来操作。比如i = 2,j = a,那么比对ij = 2a,其实也可以进行CAS操作。

③ 使用锁机制来实现原子操作

        JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁(重量级锁),其中除了偏向锁,都采用了循环CAS,即当一个线程想要进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁。

二、扩展学习:

1、JAVA对象 = 对象头 + 实例数据 + 对齐填充。

        在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header),实例数据(Instance Data)和对齐填充(Padding)。

        对象头由两部分组成,一部分用于存储自身的运行时数据,称之为 Mark Word,另外一部分是类型指针 Class Metadata Address,及对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的示例。另外,如果是对象是一个数组,对象头中还有一个部分是保存数组长度值的,称为Array Length。

        实例数据是对象真正存储的有效信息,也是程序代码中所定义的各种类型的字段内容。无论是从父类继承的,还是子类自己定义的,都需要记录在此。

        对齐填充仅仅是起到占位符的作用,因为JVM的自动内存管理系统要求对象的其实地址必须是8字节的整数倍,而对象头部分刚刚好是8字节(32bit)的整数倍,所以加上实例数据之后,需要填充来保证对象头的8字节对齐。公式:(对象头 + 实例数据 + padding) % 8等于0且0 <= padding < 8

2、为什么释放锁也需要循环CAS?

        我的理解是:这里的循环CAS的目的有部分是在查找,是否有更高优先级的线程出现,如果是被抢占等其他情况,就要立刻释放锁。当然还有其他的原因存在。

        这一章虽然篇幅很短,第一遍读起来也很快,但是回顾的时候就会发现有很多地方的概念很模糊,甚至需要参考深入理解JVM那本书上的知识点。所以,后续看完深入理解JVM那本书之后,还需要来看这些知识点。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值