探索Java并发机制底层实现原理

探索java并发机制底层实现原理

  • CPU多级缓存
  • volatile
  • Synchronized
    • Java对象头
    • 锁的升级和对比
      • 偏向锁
      • 轻量级锁
    • 锁的优缺点对比
  • 原子操作的实现原理
    • 处理器实现原子操作
    • java实现原子操作

因为会涉及到很多的知识点和术语,先复习一下,尽量把它们串成线。

Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终需要转化为汇编指令在CPU上面执行,Java中所使用的并发机制依赖于JVM的实现和CPU的指令。
在这里复习一下,Java类加载的部分知识和逻辑图。
首先了解retentionPolicy这个枚举类,这个枚举类包含了三个枚举:

SOURCE:   标记为源码状态
CLASS:       二进制字节码状态
RUNTIME:   运行时的状态

当运行我们自己写的类的时候,这个类的状态就是SOURCE,就是我们写的源码,当运行它的时候会编译成.class的字节码文件也就是CLASS状态,然后会被加载到JVM里,也就是RUNTIME状态。
探索Java并发机制底层实现原理_第1张图片
JDK中classLoader类负责类的加载,大家有兴趣可以去看源码,或者用javap -v 类名 可以看到类加载的详细信息。
类加载的过程:加载 -> 验证 -> 准备 -> 解析 -> 初始化
详细过程大家有兴趣可以去自行了解,这里就不介绍了。

再介绍一下什么是“上下文的切换

即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停的切换线程执行,让我们感觉上是多个线程在同时执行,时间片一般是几十毫秒。
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务,但是在切换前会保留上一个任务的执行状态,以便下次切换回这个任务的时候可以再加载这个任务的状态,所以任务从保存到再加载的过程就是一次上下文的切换。

CPU多级缓存

在并发的知识里面涉及到一些锁、缓存一致性等知识,为了便于理解,还是先复习一下CPU多级缓存知识吧。
先盗张图^ _ ^
探索Java并发机制底层实现原理_第2张图片
局部性原理了解一下:
时间局部性: 如果某个数据被访问,那么在不久的将来可能被再次访问。
空间局部性: 如果某个数据被访问,那么与它相邻的数据也可能被访问。

什么是CPU多级缓存?
CPU缓存位于CPU与内存之间的临时存储器,它的容量比内存小但交换速度快。在缓存中的数据是内存中的一小部分,但这一小部分是短时间内CPU即将访问的,当CPU调用大量数据时,就可避开内存直接从缓存中调用,从而加快读取速度。我们知道CPU频率太快了,快到主存跟不上,在没有缓存的情况下,CPU要处理需要从主存内获取的数据,那么CPU就需要常常等待主存,从而浪费资源。所以缓存的出现,是为了缓解CPU和主存之间速度的不匹配问题。

左图和右图的对比就是在原有的缓存基础上,分出来一级缓存,二级缓存。在只有一级缓存的情况下(假设一级缓存中的有效数据占总有效数据的80%),当CPU没有命中缓存中的数据时,那么就会访问主存。那么二级缓存的意义就在于,在一级缓存没有被命中的情况下,再去二级缓存里去命中数据(假设命中率也为80%),那么此时只有5%的数据需要在内存中调用,这样就大大太高了CPU的效率,同理三级缓存也是一样的道理,最终的目的都是尽量减少CPU对主存的直接调用

为了保证CPU访问时有较高的命中率,缓存中的内容应该按一定的算法替换。一种较常用的算法是“最近最少使用算法”(LRU算法),它是将最近一段时间内最少被访问过的行淘汰出局。因此需要为每行设置一个计数器,LRU算法是把命中行的计数器清零,其他各行计数器加1。当需要替换时淘汰行计数器计数值最大的数据行出局。这是一种高效、科学的算法,其计数器清零过程可以把一些频繁调用后再不需要的数据淘汰出缓存,提高缓存的利用率。(是不是想起了redis的内存淘汰机制 ^_ ^~~)

CPU的术语

术语 英文单词 术语描述
内存屏障 memory barriers 是一组处理器指令,用于实现对内存操作的顺序限制
缓冲行 cache line CPU高速缓存中可以分配的最小存储单位,处理器填写缓存行时会加载整个缓存行
原子操作 atomic operations 不可中断的一个或一系列的操作
缓冲行填充 cache line fill 当处理器识别到从内中读取操作数是可缓存的,处理器读取整个高速缓存行到适当的内存(L1,L2,L3,或所有)
缓存命中 cache hit 如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取
写命中 wtite hit 当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到主存,这个操作被称为写命中
写缺失 write misses the cache 一个有效的缓存行被写入不存在的内存区域

缓存一致性协议(MESI)我看到很多人都说的很详细了,它很重要大家先去看一下吧(一定要看。。。)。

volatile

Java中的大部分容器和框架都依赖于volatile和原子操作的实现原理,了解这些原理对我们并发编程会更有帮助。
在多线程并发编程中synchronized和volatile都扮演着重要角色,volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量可见性。可见性的意思是当一个线程修改一个共享变量时,另外一个线程可以读到这个修改的值(这句话包含了线程之间是如何保证通信的,共享内存和消息传递两种),如果volatile使用的恰当的话,它比synchronized的使用执行成本更低(在下文介绍),因为它不会引起上下文的切换和调度(这句话是重点哦,想想自旋锁是不是这样呢)。下面开始分析volatile:
volatile在java语言规范中是这样定义的:java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排它锁单独获得这个变量。(排它锁也称独占锁,同一时刻只有一个线程可以获得该锁,其他线程想获得该锁只有等待该锁被释放)

那么!volatile是如何保证可见性的呢,在对volatile进行写操作时在多核处理器下会发生两件事:

  • 1.将当前处理器缓存行的数据写回到系统内存
  • 2.这个写回内存的操作会使在其他CPU里缓存了该内存地址的缓存行无效。(如果不明白,去了解一下MESI)

为了提高处理速度,处理器不直接和内存通信,而是先将系统内存的数据读到内部缓存(CPU多级缓存)后再进行操作,但操作完全不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会像处理发送一条LOCK前缀(通过JIT编译器,在对volatile进行写操作的时候,会多出一行汇编代码,这段汇编代码包含LOCK前缀)的指令,将这个变量所在的缓存行的数据写回到系统内存。但是就算写回到内存,如果其他处理器缓存的值还是旧的,那么就会导致计算结果的错误,所以为了保证多处理器的缓存一致,就实现了缓存一致性协议(MESI)。每个处理器通过嗅探在总线(上图中的bus)上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效的状态,当处理器对这个数据进行修改的时候就会重新从系统内存中把数据读到处理器缓存中。

如果了解了CPU的多级缓存、上下文的切换、MESI其实这段话就很好理解了。

Synchronized

在多线程并发编程中synchronized一直都是元老级角色,很多人都称呼它为重量级锁,但是在Java SE 1.6以后它就不那么“重”了,下面介绍为了减少获得锁和释放锁带来的性能消耗引入了偏向锁和轻量级锁,锁的存储结构和升级过程。

先来看一下synchronized是如何实现同步的:Java中的每一个对象都可以作为锁,具体表现为三个方面。

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

当一个线程试图访问同步代码块时,他首先必须得到锁,退出或抛出异常时必须要释放锁,那么锁到底在哪里,锁里面包含什么信息呢????

在JVM规范中可以看到synchronized的实现原理,JVM基于进入和退出的monitor对象来实现方法同步和代码块同步,代码块同步是使用monitorenter和monitorexit指令实现的,方法同步使用的是另一种方法,但是也可以用这两个指令来完成。

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

Java对象头

synchronized用的锁是存在java对象头里的,如果对象是数组类型,则虚拟机用3个字宽存储对象头,如果是非数组类型则用两个字宽存储对象头,在32位虚拟机中,1个字宽等于4个字节等于32bit。

对象头的长度如下表

长度 内容 说明
32/64bit Mark Word 存储对象的hashcode或锁信息等
32/64bit Class Metadata Address 存储到对象类型数据的指针
32/64bit Array length 数组的长度(如果当前对象是数组)

java对象头里的mark work默认存储对象的hashcode、分代年龄和锁标志位。

对象头的存储结构如下表:

锁状态 25bit 4bit 1bit是否是偏向锁 2bit锁标志位
无锁状态 对象的hashcode 对象分代年龄 0 01

锁的升级和对比

锁一共有四种状态,级别从低到高分别是:无所状态、偏向锁状态、轻量级锁状态和重量级锁状态。
这几种锁的状态会随着竞争状态逐渐升级,锁可以升级但是不能降级,这种只能升级不能降级的策略,目的是为了提高获得锁和释放锁的效率。

想了想,通过一段代码说一下CAS的原理

public final int getAndAddInt(Object var1, long var2, int var4){
	int var5;
	do{
		var5 = this.getIntVolatile(var1, var2);
	}while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4))
	return var5;
}

这段代码是atomicInteger的incrementAndGet方法的源码,分析一波。

  • var1是我们需要传入的对象
  • var2是对象对应的值
  • var5就是内存里对应的值即期望值
  • var4就是我们要改变的值
  • 假如我们进行1+2的操作
  • var2和var5都是1
  • var4是2
  • CAS的底层原理就是先让var2和var5判断是否相等,也就是判断传入对象的值和内存的值是否相等,如果不相等说明可能其他线程已经修改的了该值,那么就返回do中的逻辑,反之就可以进行相加操作。这样反复判断var2和var5是否相等,就保证了结果的准确性。

偏向锁

在多数情况下,锁不仅不存在多线程竞争,而且总是同一个线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁,当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程进入和退出同步块时不需要CAS操作来加锁和解锁,只需简单的测试一下对象头的markword里是否存储着指向当前线程的偏向锁,如果成功表示该线程已经获得了锁,反之需要再测试一下markword中的偏向锁的标识是否为1,如果没有则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁的撤销

  • 偏向锁使用了一种等到竞争出现才会释放锁的机制,所以当其他线程尝试竞争偏向锁的时候,持有偏向锁的线程才会释放锁,偏向锁的撤销需要等到全局安全点(这个词在GC中经常出现,即在这个时间点上没有正在执行的字节码)。他会首先暂停有用偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果不处于活动状态,则将对象头设置为无锁状态,如果还活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的mark word要么重新偏向其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

读了很多遍才读懂,上图吧:
偏向锁的获取和撤销图
探索Java并发机制底层实现原理_第3张图片

轻量级锁

轻量级锁加锁

  • 线程在执行同步块之前,JVM会现在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word(在这里在强调一遍,synchronized的锁存在于java对象头的,而Mark Word存在于对象头的,包含了锁信息和JVM信息等。不要弄混了。。。)复制到锁记录中,官方称为Displaced Mark Word,然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针,如果成功,当前线程获得锁,如果失败表示其他线程竞争锁,当前线程便尝试使用自旋来获得锁。

轻量级锁解锁

  • 轻量级锁解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生,如果失败,则表示当前锁存在竞争,锁就会膨胀为重量级锁。因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了,会导致线程饥饿的问题),一旦锁升级为重量级锁,就不会再恢复到轻量级锁状态,当锁处于这个状态下,其他线程试图获取锁时,就会被阻塞住,当持有锁的线程释放锁后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
    探索Java并发机制底层实现原理_第4张图片

锁的优缺点对比

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

原子操作的实现原理

处理器实现的原子操作,我还没有完全理解,简单介绍一下。

处理器实现原子操作

1.使用总线锁

  • 总线锁:所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。

2.使用缓存锁定

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

java实现原子操作

在java中可以通过使用锁和CAS操作来实现原子操作
1.使用循环CAS操作实现原子操作

public class Counter {
	
	private AtomicInteger atomicI = new AtomicInteger(0);
	private int i = 0;
	public static void main(String[] args) {
		final Counter cas = new Counter();
		List<Thread> threads = new ArrayList<Thread>(600);
		long start = System.currentTimeMillis();
		for(int j = 0; j<100; j++) {
			Thread t = new Thread(new Runnable() {
				
				@Override
				public void run() {
					// TODO Auto-generated method stub
					for(int i = 0; i < 10000; i++) {
						cas.count();
						cas.safeCount();
					}
				}
			});
			threads.add(t);
		}
		for(Thread t : threads) {
			t.start();
		}
		//等待所有线程完成
		for(Thread thread : threads) {
			try {
				thread.join();
			} catch (Exception e) {
				// TODO: handle exception
			}
		} 
		System.out.println(cas.i);
		System.out.println(cas.atomicI.get());
		System.out.println(System.currentTimeMillis() - start);
	}
	
	/**
	 * 使用cas实现线程安全计数器
	 */
	private void safeCount() {
		for(;;) {
			int i = atomicI.get();
			boolean suc = atomicI.compareAndSet(i,  i++);
			if (suc) {
				break;
			}
		}
	}
	
	/**
	 * 非线程安全计数器
	 */
	private void count() {
		i++;
	}
}

从JDK1.5开始,JDK的JUC并发包提供了一些类来支持原子操作,如AtomicInteger、AtomicBoolean等等,这些原子包装类还提供了有效的工具方法,比如以原子的方式将当前值自增1或者自减1.

2.CAS实现原子操作的三大问题
CAS虽然很高效的解决了原子操作,但是CAS仍然还存在三大问题,ABA问题、循环时间开销大以及只能保证一个共享变量的原子操作。

  • ABA问题
    CAS需要在操作值的时候检查值有没有变化,如果没有变化则更新(想想上面的CAS栗子),但是如果一个值原来是A,后来变成了B,最后又变成了A,那么CAS进行检查时就发现它的值没有发生变化,但是实际上发生了变化,ABA问题的解决思路就是版本号,每次变量更新的时候版本号就加一,那么A->B->A就会变成1A->2B-3C。从JDK1,5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题,根据双重判断值和标记来实现原子的更新操作。
  • 循环时间长开销大
    自旋CAS长时间不成功,会给CPU带来非常大的执行压力
  • 只能保证一个共享变量的原子操作
    当对一个共享变量执行操作时,我们可以使用CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候可以用锁,或者是把多个共享变量合成一个共享变量。

3.使用锁机制来实现原子操作
锁机制保证了只有获得锁的线程才能操作锁定的内存区域,JVM内部实现了很多种锁机制,有偏向锁、轻量级锁、互斥锁等。除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS来释放锁。

Java并发编程中的大部分容器和框架都是依赖于这些原理和概念,了解了这些知识对以后的并发编程更有帮助。
本博客文章皆出于学习目的,个人总结或摘抄整理自网络。引用参考部分在文章中都有原文链接,部分引用于方腾飞老师的著作《Java并发编程的艺术》推荐小伙伴们去看,如疏忽未给出请联系本人。另外,如文章内容有错误,欢迎各方大神指导交流。

你可能感兴趣的:(java)