本书特色
-
结合JDK源码介绍了Java并发框架、线程池的实现原理。
-
不仅仅局限于Java层面,更深入JVM,CPU。
-
结合线上应用,给出了一些并发编程实战技巧。
第一章 并发编程的挑战
如果希望通过多线程执行任务让程序运行得更快,会面临非常多得挑战,如上下文切换的问题、死锁的问题,以及受限于硬件和软件的资源限制问题。
1.1 上下文切换
单核处理器支持多线程执行代码,CPU通过给每个线程分配COU时间片来实现。
时间片非常短,所以CPU通过不停的切换线程执行,让我们感觉多个线程是同时执行的,几十毫秒ms。
CPU通过时间片分配算法来循环执行任务。任务从保存到加载的过程就是一次上下文切换。
上下文切换回影响多线程的执行速度。
1.1.1 多线程一定快吗
累加,并发执行的速度比串行慢是因为线程有创建和上下文切换的开销。
1.1.2 测试上下文切换次数和时长
-
Lmbech3可以测量上下文切换的时长。
-
cmstat可以测量上下文切换的次数。
CS(Content Switch)表示上下文切换的次数。
1.1.3 如何减少上下文切换
较少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。
- 无所并发编程:多线程竞争锁时,回引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
- CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
- 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样回造成大量线程都处于等待状态。
- 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
1.1.4 减少上下文切换实战
通过见扫线上大量WAITING的线程,来减少上下文切换次数。
1.2 死锁
避免死锁的几个常见方法:
- 避免一个线程同时获取多个锁。
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
- 尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。
- 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的的情况。
1.3 资源限制的挑战
(1)什么是资源限制
资源是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。
硬件资源限制:带宽的上传/下载速读、硬盘读写速度和CPU的处理速度。
软件资源限制:数据库的连接数和scoket连接数。
(2)资源限制引发的问题
在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行,但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不会加快执行,反而会更慢,因为增加了上下文切换和资源调度的问题。
(3)如何解决资源限制的问题
对于硬件资源限制,可以考虑使用集群并行执行程序。既然单机的资源有限制,那么就让程序在多级上运行。比如使用ODPS、Hadoop或者自己搭建服务器集群,不同的机器吹不同的数据。可以通过“数据ID%机器数”,计算得到一个机器编号,然后由对应编号的机器处理这笔数据。
对于软件资源限制,可以考虑使用资源池将资源复用。比如使用连接池将数据库和Socket连接复用,或者调用对方webservice接口获取数据时,只建立一个连接。
(4)在资源限制情况下进行并发编程
如何在资源限制的情况下,让程序执行得更快呢?方法就是,根据不同得资源限制调整程序得并发度。
1.4 本章小结
本章介绍了在进行并发编程时会遇到的几个挑战,并给出了一些建议。
第二章 Java并发机制的底层实现原理
JVM执行字节码,最终需要转化为汇编指令在CPU上执行,Java中所使用得并发机制依赖于JVM得实现和CPU的指令。
2.1 volatile的应用
1.volatile的定义与实现原理
volatile是轻量级的synchronized,在多处理器开发中保证了共享变量的“可见性”。使用得当的话,比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。本文分析Intel处理器是如何实现volatile的。
CPU的术语定义:
术语 | 英文单词 | 术语描述 |
---|---|---|
内存屏障 | memory barriers | 是一组处理器指令,用于事项对内存操作的顺序限制 |
缓冲行 | cache line | 缓存中可以分配的最小存储单位。处理器填写缓存线时会加载整个缓存线,需要使用多个主内存读周期 |
原子操作 | atomic line | 不可终端的一个或一系列操作 |
缓存行填充 | cache line fill | 当处理器试别到内存中读取操作数时可缓存的,处理器读取整个缓存行到适当的缓存(L1,L2,L3的或所有) |
缓存命中 | cache hit | 如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取 |
写命中 | write hit | 当处理器将操作数写回到一个内存缓存的区域时,它首先回检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中 |
写缺失 | write misses the cache | 一个有效的缓存行被写入到不存在的内存区域 |
为了提高处理速度,处理器不直接个内存进行通信,二十先将系统内存的数据督导内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会像处理器发送一条Lock前缀的指令,将这个变量所在缓存的数据写回到系统内存。但是就算写回到内存,如过其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,再多处理器下,未了保证各个处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的换粗你一直,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,回重新从系统内存中把数据读到处理器缓存里。
Lock前缀的指令在多核处理器下会引发两件事情:
- 将当前处理器缓存行的数据写回到系统内存
- 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效
volatile的两条实现原则:
- Lock前缀指令会引起处理器缓存回写到内存
- 一个处理器的缓存回写到内存会导致其他处理器的缓存无效
2.volatile的使用优化
追加字节到64字节,因为高速缓存行普遍是64字节宽,不支持部分填充缓存行。如果队列的头节点和尾节点都不足64字节的话,处理器会将它们都读到同一个高速缓存行中,在多处理器下每个处理器都会缓存同样的头、尾节点,当一个处理器试图修改头节点时,会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己告诉缓存中的尾节点,而队列的入队和出队操作则需要不停修改头节点和尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率。
追加到64字节的方式来填满告诉缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存行,使头,尾节点在修改时不会互相锁定。
一下两种场景不适用:
- 缓存行非64字节宽的处理器。
- 共享变量不会被频繁地写。
Java7之后不能生效,会淘汰或重新排列无用字段,需要采用其他追加字节的方式。
2.2 synchronized的实现原理与应用
syschronized实现同步的基础:Java中的每一个对象都可以作为锁。具体表现以下3种形式:
- 对于普通同步方法,锁是当前实例对象。
- 对于静态同步方法,锁是当前类的Class对象。
- 对于同步方法块,锁是Synchronized括号里配置的对象。
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。
JVM基于进入和退出Monitor对象来实现方法同步和代码块同步。
monnitorenter指令===>同步代码块开头,monitorexit===>方法结束处和异常处。两者配对。任何对象都有一个monitor与之关联,当一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。
2.2.1 Java对象头
synchronized用的锁时存在Java对象头里的。
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode或锁信息 |
32/64bit | Class Metadata Address | 存储到对象类型数据的指针 |
32/32bit | Array leng | 数组的长度(如果当前对象是数组) |
Mark Word的默认存储结构:
锁状态 | 25bit | 4bit | 1bit偏向锁 | 2bit锁标志位 |
---|---|---|---|---|
无锁状态 | 对象的hashCode | 对象分代年龄 | 0 | 01 |
运行期,Mark Word里存储的数据会随着锁标志位的变化而变化。
2.2.2 锁的升级与对比
锁从低到高四个状态:无状态所、偏向锁状态、轻量级锁状态和重量级锁状态。为了提高获得锁和释放锁的效率,锁可以升级但不能降级。
- 偏向锁
目的:为了让线程获得锁的代价更低。
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头地Mar Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
(1)偏向锁的撤销