02学习笔记——Java并发编程的艺术

224 阅读9分钟

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

Java代码执行过程

Java代码在编译后会编程Java字节码,字节码被类加载器加载到JVM中,JVM执行字节码,最终需要转化为汇编指令在CPU上执行。

所以说Java中所使用的并发机制依赖于JVM和CPU的指令。

volatile

volatile是一个轻量级的synchronized,在某些情况下它比synchronized使用和执行成本更低,不会引起线程上下文切换和调度,当然我们要了解它的原理,不能随意使用。

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

volatile有两个重要特性

  • 保证多线程下共享变量的可见性。
  • 禁止指令重排序

volatile保证变量可见性

有volatile变量修饰的共享变量在进行写操作的时候会多出一行汇编代码,在这行代码中有一条带有lock的指令。
带有lock的指令在多核处理器下会引发两件事情。

  1. 将当前处理器缓存行的数据写回到系统内存中。
  2. 这个写回内存的操作会使其他在CPU内缓存了该内存地址的数据无效。

为了提高处理速度,处理器是不直接和内存进行通信的,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作。JVM就会对处理器发送一条带有lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。

但是就算写回系统内存,如果其它处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器的情况下,为了保存各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是否过期了。

当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内把数据读到处理器缓存中。

synchronized

synchronized是Java并发编程中的“元老级人物”,很多人称它为重量级锁,但是在jdk1.6以后对synchronized进行优化以后,引入了偏向锁轻量级锁在很多时候他就并不那么重量了!

三种表现方式

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

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

JVM是基于Monitor对象来实现方法同步和代码块同步的,但是二者的实现细节不一样。 代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是使用另外一种方法实现的。
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法的结束处和异常处,JVM要保证每一个moniterenter必须有对应的monitorexit配对。任何对象都有一个monitor与之关联,而且一个moitor被持有以后,它将会处于锁定状态。 线程执行到monitorenter指令的时候,将会尝试去获取对象对应monitor的所有权,即尝试获取对象的锁。

Java对象头

sychronized的锁是存放在Java对象头中的

32位的JVM的Mark Word默认存储结构如下:

锁状态25bit4bit1bit是否为偏向锁2bit锁标志位
无锁状态对象的hashcode对象分代年龄001

在运行期间,Mark Word中存储的数据会随着锁标志位的变化而变化。

几种锁状态的升级与对比

jdk1.6后为了减少获得锁和释放锁带来的性能损耗,引入了偏向锁和轻量级锁。目前一共有4种状态,级别从低到高分别是无锁<偏向锁<轻量级锁<重量级锁。随着竞争加剧,锁会升级,但是不能降级。

偏向锁
当一个同步代码块只有一个线程访问并获得锁时,会在对象头和栈帧的锁记录里存储锁偏向的线程ID,以后该线程再次进入同步块时就不需要再进行CAS操作来加锁和解锁,只要测试一下对象头的Mark word中是否存储着指向该线程的偏向锁。

轻量级锁
当一个同步代码块有多个线程来访问时,偏向锁就会升级为轻量级锁,未获得锁的线程自旋等待。 线程在执行同步块之前,JVM会现在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的MarkWord复制到锁记录中,然后线程尝试用CAS将对象头的markwork替换为指向锁记录的指针。如果成功的话,就获取到锁,如果失败,表明有其他线程竞争锁,当前线程便尝试使用自旋来获得锁。

重量级锁
从用户态转为内核态,通过操作系统让线程直接阻塞

原子操作

处理器实现原子操作

1. 通过总线锁来保证原子性

如果多个处理器同时对共享变量进行读改写操作(i++就是经典的读改写操作,一共有三个步骤:1.从内存中读取i,将i+1,将i写回到内存中),那么共享变量同时被多个处理器进行操作,这样读改写操作就不是原子性的。

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

2. 通过缓存锁定来保证原子性。

总线锁把cpu和整个内存的通信都给锁住了,使得其他处理器不能操作其他内存地址的数据,开销太大了,所以目前处理器在某些场合下使用缓存所来代替总线锁定进行优化。

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

Java实现原子操作

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

使用循环CAS实现原子操作

JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令来实现的

它的经典使用方法如下:

for(;;){
  if(compareAndSwap(expect,update)){
   //如果当前值和预期值相同,进行修改,执行我们对应的逻辑
  }
  //预期与实际不相同,继续循环
}

使用CAS会存在三大问题

1. ABA问题

因为CAS在操作值的时候会检查值有没有发生变化,如果没有发生变化则更新。如果一个值原来是A,然后被修改为B,最后又变成了A,那么使用CAS进行检查时就会发现它的值没有发生变化,实际上却发生变化了,这就是ABA问题。

解决方法是:在变量的前面追加一个版本号,每次变量更新的时候把版本号+1,那么A-B-A就会变成1A-2B-3A,这样就能够进行判断了。

2. 循环时间长开销大

如果CAS长时间不成功会给CPU带来非常大的开销。

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

当我们对一个共享变量执行操作的时候,我们可以使用循环CAS的方式来保证原子操作,但是当对多个共享变量操作时,循环CAS就无法保证操作的原子性了!这个时候可以使用锁,只有获得锁的线程才能够够操作锁定的内存区域。