Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终需要转化为汇编指令在CPU上面执行,Java中所使用的并发机制依赖于JVM的实现和CPU的指令。
在这里复习一下,Java类加载的部分知识和逻辑图。
首先了解retentionPolicy这个枚举类,这个枚举类包含了三个枚举:
SOURCE: 标记为源码状态
CLASS: 二进制字节码状态
RUNTIME: 运行时的状态
当运行我们自己写的类的时候,这个类的状态就是SOURCE,就是我们写的源码,当运行它的时候会编译成.class的字节码文件也就是CLASS状态,然后会被加载到JVM里,也就是RUNTIME状态。
JDK中classLoader类负责类的加载,大家有兴趣可以去看源码,或者用javap -v 类名 可以看到类加载的详细信息。
类加载的过程:加载 -> 验证 -> 准备 -> 解析 -> 初始化
详细过程大家有兴趣可以去自行了解,这里就不介绍了。
再介绍一下什么是“上下文的切换
”
即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停的切换线程执行,让我们感觉上是多个线程在同时执行,时间片一般是几十毫秒。
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务,但是在切换前会保留上一个任务的执行状态,以便下次切换回这个任务的时候可以再加载这个任务的状态,所以任务从保存到再加载的过程就是一次上下文的切换。
在并发的知识里面涉及到一些锁、缓存一致性等知识,为了便于理解,还是先复习一下CPU多级缓存知识吧。
先盗张图^ _ ^
局部性原理了解一下:
时间局部性: 如果某个数据被访问,那么在不久的将来可能被再次访问。
空间局部性: 如果某个数据被访问,那么与它相邻的数据也可能被访问。
什么是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)我看到很多人都说的很详细了,它很重要大家先去看一下吧(一定要看。。。)。
Java中的大部分容器和框架都依赖于volatile和原子操作的实现原理,了解这些原理对我们并发编程会更有帮助。
在多线程并发编程中synchronized和volatile都扮演着重要角色,volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的可见性。可见性的意思是当一个线程修改一个共享变量时,另外一个线程可以读到这个修改的值(这句话包含了线程之间是如何保证通信的,共享内存和消息传递两种),如果volatile使用的恰当的话,它比synchronized的使用执行成本更低(在下文介绍),因为它不会引起上下文的切换和调度(这句话是重点哦,想想自旋锁是不是这样呢)。下面开始分析volatile:
volatile在java语言规范中是这样定义的:java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排它锁单独获得这个变量。(排它锁也称独占锁,同一时刻只有一个线程可以获得该锁,其他线程想获得该锁只有等待该锁被释放)
那么!volatile是如何保证可见性的呢,在对volatile进行写操作时在多核处理器下会发生两件事:
为了提高处理速度,处理器不直接和内存通信,而是先将系统内存的数据读到内部缓存(CPU多级缓存)后再进行操作,但操作完全不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会像处理发送一条LOCK前缀(通过JIT编译器,在对volatile进行写操作的时候,会多出一行汇编代码,这段汇编代码包含LOCK前缀)的指令,将这个变量所在的缓存行的数据写回到系统内存。但是就算写回到内存,如果其他处理器缓存的值还是旧的,那么就会导致计算结果的错误,所以为了保证多处理器的缓存一致,就实现了缓存一致性协议(MESI)。每个处理器通过嗅探在总线(上图中的bus)上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效的状态,当处理器对这个数据进行修改的时候就会重新从系统内存中把数据读到处理器缓存中。
如果了解了CPU的多级缓存、上下文的切换、MESI其实这段话就很好理解了。
在多线程并发编程中synchronized一直都是元老级角色,很多人都称呼它为重量级锁,但是在Java SE 1.6以后它就不那么“重”了,下面介绍为了减少获得锁和释放锁带来的性能消耗引入了偏向锁和轻量级锁,锁的存储结构和升级过程。
先来看一下synchronized是如何实现同步的:Java中的每一个对象都可以作为锁,具体表现为三个方面。
当一个线程试图访问同步代码块时,他首先必须得到锁,退出或抛出异常时必须要释放锁,那么锁到底在哪里,锁里面包含什么信息呢????
在JVM规范中可以看到synchronized的实现原理,JVM基于进入和退出的monitor对象来实现方法同步和代码块同步,代码块同步是使用monitorenter和monitorexit指令实现的,方法同步使用的是另一种方法,但是也可以用这两个指令来完成。
monitorenter指令是在编译后插入到 同步代码块的开始位置,monitorexit指令插入到方法结束处和异常处,JVM要保证每个monitorenter都有一个monitorexit与之对应。任何一个对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态,线程执行到monitorenter指令时,将会尝试获取对象所对应的的monitor的所有权,也就是尝试获得对象的锁。
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方法的源码,分析一波。
在多数情况下,锁不仅不存在多线程竞争,而且总是同一个线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁,当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程进入和退出同步块时不需要CAS操作来加锁和解锁,只需简单的测试一下对象头的markword里是否存储着指向当前线程的偏向锁,如果成功表示该线程已经获得了锁,反之需要再测试一下markword中的偏向锁的标识是否为1,如果没有则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁的撤销
轻量级锁加锁
轻量级锁解锁
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程,使用自旋消耗CPU | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行时间较长 |
处理器实现的原子操作,我还没有完全理解,简单介绍一下。
1.使用总线锁
2.使用缓存锁定
在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问题、循环时间开销大以及只能保证一个共享变量的原子操作。
3.使用锁机制来实现原子操作
锁机制保证了只有获得锁的线程才能操作锁定的内存区域,JVM内部实现了很多种锁机制,有偏向锁、轻量级锁、互斥锁等。除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS来释放锁。
Java并发编程中的大部分容器和框架都是依赖于这些原理和概念,了解了这些知识对以后的并发编程更有帮助。
本博客文章皆出于学习目的,个人总结或摘抄整理自网络。引用参考部分在文章中都有原文链接,部分引用于方腾飞老师的著作《Java并发编程的艺术》推荐小伙伴们去看,如疏忽未给出请联系本人。另外,如文章内容有错误,欢迎各方大神指导交流。