目录
前言:
本系列是我阅读并发编程的艺术这本书的笔记,本篇内容是系列的第一篇。
1. 并发编程的挑战
首先我们知道,并发编程的目的是为了让程序运行得更快,但是也并不是启动更多的线程就一定能让程序最大限度的并发执行。在多线程编程中为了使程序运行得更快会遇到非常非常多的挑战,例如死锁的问题,硬件软件资源的限制的问题,上下文切换问题等等。
一起来了解一下什么是上下文切换:
CPU通过给每个线程分配时间片的方式来实现支持多线程。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停的切换线程执行来让我们感觉多个线程是一起执行的。CPU通过时间分配算法来循环执行任务,当前任务执行完一个时间片后会切换到下一个任务,但是在切换时会保存上一次任务的状态,这样当下次切换回来的时候可以接着上次的状态继续执行,这样的任务从保存再到加载的过程就是一次上下文切换。
我们经常说多线程高并发,但是考虑一下,多线程真的一定快吗,书中举了一个例子
private static final long count = 10001;
public static void main(String[] args) throws InterruptedException {
// write your code here
concurrency();
serial();
}
private static void concurrency() throws InterruptedException {
long start = System.currentTimeMillis();
Thread thread = new Thread() {
@Override
public void run() {
int a = 0;
for (long i = 0; i < count; i++) {
a += 5;
}
}
};
thread.start();
int b = 0;
for (long i = 0; i < count; i++) {
b--;
}
thread.join();
long time = System.currentTimeMillis();
System.out.println("concurrency:" + time + "ms,b=" + b);
}
private static void serial() {
long start = System.currentTimeMillis();
int a = 0;
for (long i = 0; i < count; i++) {
a += 5;
}
int b = 0;
for (long i = 0; i < count; i++) {
b--;
}
long time = System.currentTimeMillis();
System.out.println("serial:" + time + "ms,b=" + b + "a=" + a);
}
经过测试,实际结果是并发执行不超过百万次时,速度会比串行执行的速度要慢,为什么并发执行的速度反而会比串行执行慢呢,答案是线程有创建和上下文切换的开销。那么要如何减少上下文切换的开销呢,方法有无锁并发编程、CAS算法、使用最少的线程和使用协程。接下来简单的解释一下这些方法
- 多线程竞争锁的时候,会引起上下文切换,所以多线程处理数据时,可以用一些方法来避免使用锁,比如将数据的ID按照Hash算法取模分段,不同线程处理不同段的数据
- CAS算法,使用Atomic包中的原子类,底层为CAS实现,不需要加锁
- 避免创建不需要的线程。
- 协程:在单线程里实现多任务的调度,在单线程中维持多个任务的切换。
多线程编程避免不了要使用锁,锁是个非常有用的工具,使用锁可以避免共享资源的竞争,但是使用锁也会一些隐患,比如说有可能会引起死锁的问题,一旦产生了死锁,将造成系统功能不可用。书中也举了一个引起死锁的例子
public class Main {
private static String A = "A";
private static String B = "B";
public static void main(String[] args) throws InterruptedException {
// write your code here
new Main().deadLock();
}
private void deadLock() {
Thread t1 = new Thread(() -> {
synchronized (A) {
try {
Thread.sleep(2000);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
synchronized (B) {
System.out.println("1");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (B) {
synchronized (A) {
System.out.println("2");
}
}
});
t1.start();
t2.start();
}
}
可以看到,t1和t2线程都在互相等待对方释放锁,这样就造成了死锁的情况。在现实中,应该不会写出这样的代码,但是在更复杂的场景中,可能会遇到这样的问题。
避免死锁的几种方法
- 避免一个线程同时获取多个锁
- 避免一个线程在锁内同时占用多个资源
- 尝试使用定时锁,使用lock.tryLock(timeout)来代替内部锁机制
- 对于数据库锁,要保证加锁和解锁在一个数据库连接内,否则会出现解锁失败的情况
2. java并发机制的底层实现原理
在多线程并发编程中,synchronized和volatile都有者重要的作用,volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的”可见性“。什么是”可见性“呢,可见性是当一个线程修改一个共享变量时,另一个线程能读到这个修改的值。如果volatile修饰使用得当的话,它比synchronized的使用和执行成本更低。
在了解volatile实现原理之前,先了解一下和它实现原理相关的CPU术语和说明
术语 | 英文单词 | 术语描述 |
---|---|---|
内存屏障 | memory barries | 是一组处理器指令,用于实现对内存操作的顺序限制 |
缓冲行 | cache line | CPU高速缓存中可以分配的最小存储单位。处理器填写缓存行时会加载整个缓存行,现代CPU需要执行几百次CPU指令 |
原子操作 | atomic operations | 不可中断的一个或一系列操作 |
缓存行填充 | cache line fill | 当处理器意识别到从内存中读取操作数时可缓存的,处理器读取整个高速缓存行到适当的缓存(L1,L2,L3的或所有) |
缓存命中 | cache hit | 如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取 |
写命中 | write hit | 当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中 |
写缺失 | write misses the cache | 一个有效的缓存行被写入到不存在的内存区域 |
volatile是怎么保证可见性的呢,根据汇编代码分析,对volatile执行写操作时,会有一行lock前缀的指令,lock前缀的指令在多核处理器下会引发两件事情
- 将当前处理器缓存行的数据写回到系统内存。
- 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
java内存模型简图
volitale所做的,就是让jvm每次都从主内存读取和写入而不是从工作内存读取和写入。
下面再来看看synchronized关键字
在多线程编程中synchronized一直是常用关键字,以前很多人都叫它重量级锁,不过,在jdk1.6对synchronized进行了各种优化后,有些情况下他就不是那么的重量级了,主要在于jdk1.6为synchronized加入了偏向锁和轻量级锁,还有锁的升级过程,下面就来一起看一下。
先看下利用synchronized实现同步的基础,java中每一个对象都可以作为锁,具体来说就是以下三种形式
- 对于普通方法,锁的是当前实例对象
- 对于静态方法,锁的是当前类的Class对象
- 对于同步代码块,锁的是Synchronized括号里配置的对象
当一个线程试图访问同步代码块时,需要先获得锁,当线程退出或者抛出异常时必须释放锁,那么锁是存在哪里的呢,锁里面存储的又是什么信息呢?
JVM通过进入和退出Monitor(监视器)来实现方法同步和代码块同步,代码块同步是使用monitorenter和monitorexit指令实现的。monitorenter指令是在编译后插入到同步代码块的开始位置,monitorexit是插入到方法结束处和异常处。JVM保证每一个monitorenter都有一个monitorexit。java中的每一个对象都有一个monitor和它关联,当一个monitor被持有后,他就处于了锁定状态。线程执行到monitor指令时,会尝试获取对象所对应的monitor的所有权,即尝试获得锁。
2.1 java对象头
由于java面向对象的思想,在JVM中需要大量存储对象,存储时为了实现一些额外的功能,需要在对象中添加一些标记字段用来增强对象的功能,这就是对象头。
在现在的64位虚拟机中,java对象头共有3部分组成(其中数组长度为可选),每部分各8个字节64位,分别是Mark word(存储对象的hashCode或锁信息等),Klass word(一个指向方法区中Class信息的指针,通过这个指针对象可以知道自己是那个类的实例),数组长度(如果当前对象是数组)。
接下来我们来深入的了解一下对象头的各个部分,首先从Mark word开始,Mark word的存储结构如下:
Mark word 64bit | ||||||
---|---|---|---|---|---|---|
对象分代年龄 | 偏向锁标记 | 锁标志位 | 锁状态 | |||
25bit unused | 31bit对象哈希值 | 1bit unused | 4bit | 0 | 01 | 无锁 |
54bit 线程ID | 2bit 偏向锁的时间戳 | 1bit unused | 4bit | 1 | 01 | 偏向锁 |
62bit 轻量级锁状态下,指向栈中锁记录的指针。 | 00 | 轻量级锁 | ||||
62bit 重量级锁状态下,指向对象监视器Monitor的指针。 | 10 | 重量级锁 | ||||
空 | 11 | GC标记 |
接下来是Klass word,Klass word为类指针,Klass word在32位虚拟机和64位虚拟机上的大小不同,32位虚拟机上Klass word大小为4bit,64位虚拟机上Klass word大小为8bit。
最后是数组长度,当对象是数组的时候,这部分数据的长度也随着JVM架构的不同而不同:32位的JVM上,长度为32位;64位JVM则为64位。64位JVM如果开启+UseCompressedOops选项,该区域长度也将由64位压缩至32位。
2.2 锁的升级与对比
jdk 1.6中,为了优化synchronized的效率,引入了“偏向锁”和“轻量级锁”这两个概念,jdk 1.6中,锁有四种状态,分别是无锁、偏向锁、轻量级锁、重量级锁。这几种状态会随着竞争情况逐渐升级,但是却不能降级,这种只能升级不能降级的策略的目的是为了提高获得锁和释放锁的效率。
1. 偏向锁
经研究发现,大多数情况下,锁不仅不存在多线程竞争情况,而且还总是由同一个线程多次获得锁,为了让线程获得锁的代价更低,所以引入了偏向锁。当一个线程访问同步块并获得锁时,会在对象头和栈帧中锁记录里存储锁偏向的的线程ID(54bit长),并将偏向锁标记置为1,代表已经上锁,当下次该线程在进入同步块时,就不用进行CAS操作来加锁,直接查看一下对象头里偏向锁标志位是否为1即可,如果为1,那就使用CAS将对象头的偏向锁指向当前线程,如果为0,那么使用CAS竞争锁。
1.1 偏向锁升级为轻量级锁
当偏向锁出现锁竞争的时候,当前线程就会判断之前拥有锁的线程现在是否还存在并且还拥有偏向锁,如果存在但不拥有偏向锁的话,就重置偏向锁并重新上锁,当存在且拥有偏向锁的时候,就会发生升级到轻量级锁,并且偏向锁会撤销,偏向锁的释放是一个耗费大量资源的事,它会等待全局安全点,然后暂停拥有偏向锁的线程,检查线程是否活着,然后进行一系列的操作最后释放掉线程上的偏向锁,所以,当应用中会大量出现锁竞争的情况时,最好是不要偏向锁,那么就可以在启动的时候设置-XX:-UseBiasedLocking = false来关闭偏向锁。
2. 轻量级锁
2.1 轻量级锁的加锁过程
线程在执行同步块之前,JVM会现在当前线程的栈帧中创建用于存储记录的锁空间,然后把对象头中的Mark word复制进去,官方称为Displaced Mark word。然后尝试使用CAS将对象头中的Mark word替换为指向所记录的指针。如果成功则当前线程获得锁,如果失败则表示其他线程竞争锁,当前线程将自旋(线程循环等待,不停的判断锁是否能被成功获取)来获取锁。
2.2 轻量级锁解锁
轻量级锁解锁时,会用CAS操作将Displaced Mark word替换回对象头,如果成功则表示没有竞争,失败则表示当前锁存在竞争,这时候锁就会膨胀成为重量级锁。
下面画张图来看一看
因为自旋会消耗CPU,所以为了避免不必要的自旋,一旦锁升级成了重量级锁,就不会再恢复到轻量级锁状态。
锁的优缺点对比:
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法速度差距很小 | 如果线程之间出现锁竞争,会带来额外的撤销锁的消耗 | 使用于只有一个线程访问同步块的场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程,就会一直自旋,消耗CPU | 追求响应时间 同步块执行速度非常快 |
重量级锁 | 竞争的线程会阻塞,不会自旋 |
3. java原子操作的实现原理
原子操作的意思是“不可被中断的一个或一系列操作”,在多处理器上实现原子操作有点复杂,下面来看看intel处理器和java里是如何实现原子操作的。在这之前,要先了解一下相关术语
3.1 术语定义
术语名称 | 英文 | 解释 |
---|---|---|
缓存行 | Cache line | 缓存的最小操作单位 |
比较并交换 | Compare and Swap | CAS操作需要两个数值,一个旧值(期望操作的值)和一个新值,在操作期间先比较旧值有没有变化,如果没有发生变化就交换新值,如果发生变化了就不交换值 |
CPU流水线 | CPU pipeline | CPU流水线的工作方式就像工厂里的流水线,在CPU中由5-6个不同功能的电路单元组成一条指令处理流水线,然后将一条X86指令分成5-6步后再由这些电路单元分别执行,这样就能实现在一个CPU时钟周期完成一条指令,因此提高CPU的运算速度 |
内存顺序冲突 | Memory order violation | 内存顺序冲突一般是由假共享引起的,假共享是指多个CPU同时修改同一个缓存行的不同部分而引起其中一个CPU的操作无效,当出现这个内存顺序冲突的时候,CPU必须清空流水线 |
3.2 处理器如何实现原子操作
32位的处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作,处理器首先会保证基本内存操作的原子性。处理器保证从系统内存中读取或者写入一个字节是原子的,也就是当一个处理器读取一个字节的时候,其他处理器不能访问这个字节的内存地址。处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。
总线锁保持原子性
多个处理器同时从各自的缓存中读取变量i,分别进行加i操作,然后分别写入系统内存中。想要保证读写变量的操作是原子的,就必须保证一个CPU在读写共享变量的时候,其他的CPU不能操作缓存了该共享变量内存地址的缓存。
处理器使用总线锁,提供一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将会被阻塞住,那么该处理器就可以独占共享内存。
缓存锁保持原子性
同一时刻,我们只需要保证对某个内存地址的操作是原子性的就可以了,但是总线锁把CPU和内存之间的通信锁住了,这导致锁定期间,其他处理器不能操作其他内存地址的数据,所以开销比较大,目前的处理器会在某些场合下使用缓存锁代替总线锁。
频繁使用的内存会缓存在处理器的L1、L2、L3高速缓存里,那么原子操作就可以直接在缓存中进行,所谓缓存锁的意思就是指内存区域如果被缓存在处理器缓存行中,而且在Lock操作期间被锁定,那么它执行做操作回写到内存时,处理器不在总线上发出Lock#信号,而是修改内部的内存地址,并通过缓存一致性协议来保证操作的原子性。缓存一致性协议会阻止同时修改两个以上处理器缓存的内存区域数据。
3.3 java如何实现原子操作
java中实现CAS操作是利用了处理器提供的CMPXCHG指令。自旋CAS就是循环进行CAS操作知道成功为止。
来看一段代码
public class Counter {
private AtomicInteger atomicI = new AtomicInteger(0);
private int i=0;
public static void main(String[] args) {
// write your code here
final Counter cas = new Counter();
List<Thread> ts = new ArrayList<>(600);
long start = System.currentTimeMillis();
for (int j = 0; j < 100; j++) {
Thread t = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
cas.count();
cas.safeCount();
}
});
ts.add(t);
}
for (Thread t : ts) {
t.start();
}
//等待所有线程执行完成
for (Thread t : ts) {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
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++;
}
}
java从jdk1.5开始在并发包里提供了一些原子操作的类美如AtomicInteger(原子方式更新Int值),AtomicBoolean(原子方式更新boolean值)等等,同时这些原子操作包装类还有一些有用的工具方法,比如以原子方式自增和自减。
但是CAS原子操作也是有自己的问题存在的,首先,CAS会产生ABA问题,什么是ABA问题呢,因为CAS在操作值的时候要先判断值有没有发生变化,如果没变化就更新,但是问题来了,如果一个值原来是A,变成了B,又变成了A,CAS就会把它当做没有变化,但是实际上是变化了的。这个问题有一个解决方法就是加上版本号,使得每次变量更新的时候都在变量前把版本号加1,这样的话A-B-A就会变成1A-2B-3A。jdk1.5开始,Atomic包里提供了一个AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法首先检查当前引用是否等于预期引用,并检查当前标志是否等于预期标志,如果全部相等,就用原子方式将该引用和该标志的值设置为给定的值。
CAS如果长时间不成功的话,会给CPU带来比较大的开销,这也是CAS的一大缺点,还有就是CAS只能对一个共享变量保证原子操作,但是多个共享变量就不行
总结
第一部分我们讨论了并发编程的挑战,研究了volatile,synchronized和原子操作的实现原理。java中大部分容器和框架都依赖与volitale和原子操作的实现原理,了解这些对我们进行并发编程大有好处。