Java Concurrency Program
1.摘要
本篇博客对《Java并发编程实战》一书进行总结,较为系统地对JCP的主要内容进行介绍,主要包括:并发和多线程引入的问题与解决、线程池的使用和线程的协调,以便加深理解和记忆。
2.书本导读
2.1简介
《Java并发编程实战》是讲解Java并发编程的经典书籍,是由JCP官方专家组出品的教材,在国内市场上具有多个翻译版本,也是国内Java从业人员学习并发编程最为流行的书目。
笔者在阅读此书并进行总结陆续花费了近3周的时间,每天的阅读总结时间在2-3小时左右。
![](https://img-blog.csdnimg.cn/16f0a17a4cc545f1b67ea02d7a83ce92.png)
正如书中作者所言:
1)并发编程已经不再是某种神秘而高级的主题,而是被广泛使用的通识技术,因而对于应用程序的开发人员,尤其是Java服务端开发人员而言,并发编程是不得不学习的主题。
2)此书并非是学习并发编程的入门指南,也不是关于并发编程百科全书似的参考手册,而是提供了实际的设计规则和设计模式。
总体而言,本书的内容较为全面,涉及但不过多地涉及底层,学习时需要一定基础,配有更多实例,更加面向高层的设计规则和模式。
2.2问题
但是,笔者在阅读此书时,发现如下几个问题:
1)此书的内容较为陈旧,书中的主流JDK版本在6.0左右,出版时的大背景仍是多核处理器的普及,因而要学习最新的JCP知识还需要参考更新的教材或文章。
2)此书为了方便读者的理解,举了许多贴近生活的例子,以及对一些概念名词进行了“似乎”生动形象的比喻。不过这些“好心”的做法,由于翻译原因和中外文化差异,未能发挥出它设想的作用,反而一定程度上增加了阅读和理解的难度。
3)此书的编排顺序为了起到循序渐进,逐步深入的作用,将一些相关的内容分散到了前后多个章节,章节间的跨度大,这就导致了知识的分散和重复,且章节间的组织结构不够清晰。
为此,笔者在进行本篇博客的总结时,会对内容进行重排,尽可能地以更为清晰、系统的结构进行总结,并删繁就简,突出重点。
3.为何使用并发和多线程
尽管使用并发编程和多线程的原因能够列举出多条,不过最关键的原因只有一个:充分发挥处理器资源的计算能力
这里引入与高并发的相反概念:弱并发:限制并发调用量的并非是可用的处理器资源,而是由应用程序自身结构的限制。
由此可知,为了更好地发挥计算机处理器的计算能力和系统的吞吐量,我们应该引入多线程和并发编程,并努力提高应用程序的并发量。
4.引入并发和多线程带来的问题
引入并发编程和多线程带来的首个问题是增加了编码难度,相对于控制流单一的串行程序,并发程序更难以编写。
除此之外,并发的应用程序还包括以下几种问题,笔者会在后面的章节逐个介绍:
1)安全性问题
2)活跃度问题
3)性能问题
4)可伸缩性问题
5.安全性问题
安全性问题是并发应用程序的首要问题,它关乎一个程序是否正确。应用程序引入多线程和并发性会更加容易出错,且这种错误难以发现,时有时无,且在系统状态最不佳的情况下(如高负载)更容易出现。
5.1线程安全的基本概念
1)安全性问题,即线程安全问题,总的来说:是维护数据的一致性问题,就是对共享的、可变的状态变量访问的管理,其中:
①状态变量:实际是指类中的静态变量或对象中的成员变量
②共享:是指该变量可由多个线程并发访问
③可变:是指该变量的值可以发生改变
具体地,线程安全问题由三种原因造成:并发执行时序和复合操作、可见性、重排序,这三个问题的本质上都是因为高层程序设计的语义与底层系统架构的执行方式不符造成的,其根本原因在于底层系统架构为提高系统性能的设计诱发的正确性隐患。
2)线程安全的类
①定义:如果不考虑多线程状态下,这些线程的调度和交替执行,也不需要调用方进行额外的同步,调用这个对象的任何行为都可以得到满足先验和后验条件的正确结果,那么这个对象就是线程安全的。
②线程安全类的常用安全强度
- 相对线程安全:通常意义的线程安全,多线程情况下,对一个对象任何单独的访问操作都是安全的,即上面描述的线程安全类的定义。
- 线程兼容:通常意义的线程不安全,多线程情况下,一个对象本身不是线程安全的,但调用端可以通过正确的同步手段来保证该对象的线程安全。
3)线程安全的类和线程安全的程序的关系
线程安全的程序中包含的类并非都必须是线程安全的类;完全由线程安全的类组成的程序并非一定是线程安全的程序。
5.2并发执行时序和复合操作
1)原因剖析
①并发执行时序问题:由操作系统造成,操作系统在执行多线程程序时,是并发执行的,即执行每个单独线程的时间和先后顺序是不确定的,线程间还存在交替执行的情况,这就给数据的不一致性带来了隐患。
②看似原子操作的复合操作:一些看似为原子操作的高层程序设计语义在底层系统架构的实际执行时,并非是原子操作,而是复合操作,这又给数据的不一致性带来了隐患。
2)举例:竞争条件
竞争条件是一种典型的线程安全问题,用以对上面的两点原因进行解释。
竞争条件是指:计算的正确性依赖于运行时相关的时序或多线程的交替,最常见的一种竞争条件是“检查再运行”,如计数器问题:
public class Access{
static int count = 0;
public static void main(String[] args) {
for(int i=0;i<2;i++){
new Thread(()->{
for(int j=0;j<10;j++){
count ++;
System.out.println(count);
}
}).start();
}
}
}
示例代码中,我们开启了两个线程分别对count变量进行20次自增运算,我们希望的正确结果是:count=20,但实际的运行中,count可能会出现19等其他情况,在自增运算的过程中可能会打印出相同的值。这正是由于上述剖析的两个原因造成的,如下面的情况:
线程A:获取count的当前值为0 → 加1运算 → count计算后的值为1
线程B:线程A的运算未结束,获取count的当前值为0 → 加1运算 → count计算后的值为1(而不是2)
由此可见,由于线程的实际运行时序不定,且对状态变量访问的非原子性会导致线程安全的问题。除了竞争条件以外,还存在其他的线程安全问题。
3)解决并发时序和复合操作的方法:同步
①打破共享、可变任一条件
②锁
③同步容器
④原子变量
具体的保证线程安全的方法将在后面的章节进行介绍。
5.3可见性
1)可见性问题剖析
可见性问题是指多线程状态下,一个线程对某一变量的修改对其他线程是不可见的。
其本质原因在于底层系统架构提供了不同的缓存一致性,即为了提供处理器利用率,各个处理器修改的变量(它是内存中某一变量的拷贝)会保存于其寄存器中,周期性地更新内存中对应的变量,而不会实时更新,Java存储模型还规定了各个线程中的临时变量是对内存变量的复制,其各个线程的临时变量相互隔离,这就造成了可见性问题。
![](https://img-blog.csdnimg.cn/99309805f06b4882adb7fcb6a8db8783.png)
2)可见性问题举例:不安全的发布
如下例,assertSanity这一看似不可能发生的事件,在并发情况下也存在发送的可能。这是由于发布对象的线程的结果可能对其余消费的线程不可见,导致其余线程读到一个n=0的过期值(实事求是的说,笔者未能测试出这种异常的情况)
public class Holder {
private int n;
public Holder(int n){
this.n = n;
}
public int assertSanity(){
if(n != n)
throw new AssertionError();
return n;
}
}
public class NotSafePublish {
static Holder holder;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{holder = new Holder(10);}).start();
for(int i=0;i<100;i++){
new Thread(()->{
try {
System.out.println(Thread.currentThread().getId()+ ":" + holder.assertSanity());
}catch (NullPointerException e){
System.out.println(Thread.currentThread().getId());
}
}).start();
}
}
}
3)保证可见性的方法
①volatile变量
②锁
③同步容器
④原子变量
保证可见性的具体方法会在后面的章节进行介绍。
4)安全发布的模式
发布:是指一个类被实例化,对象创建的位置通常与对象的描述位置不同
正确的初始化意味着:在构造函数中this不会逸出(通常是在构造函数中启动线程或注册事件监听),即在一个对象完全构造完成之后再使用它。
①通过静态初始化器初始化对象的引用
静态初始化是由JVM完成的,发生在类的初始阶段,即类被加载后到任意线程使用之前。JVM在静态初始化期间获得一个锁,该锁保证每个线程都至少会用一次,来确保一个类是否被加载,该锁也保证了无论是构造期间还是被引用时,都不需要显式地同步,这仅仅适用于构造当时。
public class EagerInitialization{
private static Resource resource = new Resource();
public static Resource getResource() {return resource};
}
惰性初始化技巧
public class ResourceFactory{
private static class ResourceHolder{
public static Resource resource = new Resource();
}
public static Resource getResource(){
return ResourceHolder.resource;
}
}
双校锁DCL
双校锁并非是正确的,需要将它保护的状态变量设为voliate才能保证正确,且它已不再使用
②初始化安全
一个正在创建的不可变对象,任何可以通过其final修饰的变量及其引用的内容都可以保证对其他线程可见(这仅仅在创建中),这称为初始化安全性,初始化安全性可以抑制重排序。
③由原子变量或volatile修饰、用线程安全容器包含、用锁保护
5.4重排序
1)重排序是指,底层平台架构和编译器为了优化高层程序设计代码,提高计算效率,会在保证串行语义不变的情况下,对程序代码进行重新排序。这个特性在单线程的情况下有利无弊,不过在多线程的情况下,会使得程序的行为难以预计,容易出错。
2)解决重排序问题的方法与保证可见性的方法相当
5.5Java存储模型
1)一种架构的存储模型通过定义一些被称为存储关卡(栅栏)的特殊指令,告诉应用程序从它的存储系统中可以获得怎样的保证。
Java通过JVM在适当的位置上插入存储关卡来,来屏蔽底层的这些不同架构的存储模型之间的差异,提供了自己的存储模型JMM(Java Memory Model)
2)JMM为程序的内部操作定义了一个偏序关系(同步操作满足全序关系),称为happens-before
,即若操作Ahappens-before
操作B,则无论A、B是否在同一个线程中,B总能看到操作A的结果;若两操作不满足happens-before
关系,则JVM可以对它们任意排序(保证串行语义的情况下)
3)happens-before法则
①Java内置的happens-before
法则
名称 | 描述 |
---|---|
程序次序法则 | 按照串行语义一致的操作Ahappens-before 操作B |
监视器加锁法则 | 对一个内置锁的解锁happens-before 对该内置锁的加锁 |
volatile变量法则 | 对volatile变量的写入happens-before 对该变量的读取 |
线程启动法则 | 线程的运行happens-before 该线程的启动 |
线程终结法则 | 线程的终结(从Thread.join成功返回、Thread.isAlive 是false)happens-before 线程的执行 |
中断法则 | 线程的interrupthappens-before 中断的发现(抛出异常、调用isInterrupted、interrupted为true) |
终结法则 | 一个对象的构造函数happens-before 该对象的析构函数 |
传递性 | 偏序关系满足传递性 |
②类库担保的happens-before
法则
名称 | 描述 |
---|---|
安全容器法则 | 将一个元素置入安全容器happens-before 从容器中获取该元素 |
闭锁法则 | 闭锁的countdownhappens-before 对该闭锁的await |
信号量法则 | 对信号量的释放happens-before 对信号量的获取 |
Future法则 | Future的执行happens-before Future.get |
线程池法则 | 向线程池置入一个任务happens-before 该任务的执行 |
关卡法则 | 任一线程到达关卡happens-before 所有线程被关卡释放 |
4)从happens-before
法则解释线程安全问题
①并发执行时序和复合操作问题:如果一个共享、可变状态变量的读写操作未按照正确的happens-before
法则排序则会出现线程安全问题,正确的同步可以保证顺序的一致性。
②可见性问题:如果对一个变量的修改操作happens-before
于对该变量的读取,则读取操作就可以获取该变量的最新值而非过期值。
③重排序问题:如果操作的执行满足于并发语义下的happens-before
法则,则可以避免重排序问题。
6.打破共享、可变的任一条件
如上面所述,维护线程安全的本质就是管理对共享的、可变的状态变量的访问,倘若我们打破共享的、可变的任一条件,就可以维护线程安全。
6.1打破共享性:线程封闭
若一个共享、可变的状态变量打破共享性后,它就无法被跨线程的共享,那么它在属于自己的线程中,对它的访问就是串行的,因而可以保证线程安全。
6.1.1 Ad-hoc线程限制
Ad-hoc线程限制在书中的定义并不是很清楚,这里仅进行简单的介绍:Ad-hoc线程限制是指线程限制性的任务全部由程序实现完成,即不借助任何可见性修饰符和本地变量等语言特性来协助将对象限制在目标线程中,这种做法非常脆弱。
6.1.2 栈封闭
栈封闭是线程封闭的一种特例,它通过将状态变量限制在线程栈中,即以方法中的局部变量的形式,来避免其余线程访问这个变量。
/* 一个无状态的servlet */
public class StatelessSevlet implements Servlet{
public void service(ServletRequest req, ServletResponse resp){
// 被分解的整数
BigInteger i = extractFromRequest(req);
// 分解后的因数
BigInteger[] factors = factor(i);
encodeIntoResponse(resp,factors);
}
}
6.1.3ThreadLocal
1)ThreadLocal为每一个线程都创建一个线程封闭的对象,可以保证各线程对象之间的隔离,如下例中,3个线程分别获取到不同hashcode值的BufferReader对象。
public class ThreadLocalDemo2 {
private static ThreadLocal<BufferedReader> threadLocal = new ThreadLocal<>(){
// 重新泛型实例的获取方法
@Override
protected BufferedReader initialValue(){
return new BufferedReader(new InputStreamReader(System.in));
}
};
public static void main(String[] args) throws InterruptedException {
ArrayList<BufferedReader> arrayList = new ArrayList<>();
for(int i=0;i<3;i++){
new Thread(()->{
BufferedReader bufferedReader = threadLocal.get();
arrayList.add(bufferedReader);
}).start();
}
// 这里使用关卡或线程池做协调更佳
Thread.sleep(3000);
for(BufferedReader bufferedReader:arrayList){
System.out.println(bufferedReader.hashCode());
}
}
}
2)但是,ThreadLocal对象容易被滥用,ThreadLocal对象常用作如数据库连接等资源对象,这样会降低对象的可重用性,引入类间耦合,需要谨慎使用。
6.2打破可变性:共享只读
如果一个对象打破了可变性,但可以跨线程共享,那么它就被称为是共享只读的变量,共享只读的变量在没有额外同步的情况下,保证线程安全,这样的变量包括不可变对象与高效不可变对象。
①不可变对象
- 该对象的状态在创建后不能修改
- 该变量的所有域都被final修饰,且由final修饰的引用的变量是线程安全的(由其自身保证)
- 该变量被正确的创建(创建期间没有发生this的逸出)
②高效不可变对象
从技术上可以进行修改,但业务或语义上不会对其修改的变量
7.锁与实例限制
7.1内置锁
Java的内置锁synchronized锁也被称为监视器锁,是Java 5.0之前就提供了的同步工具。
7.1.1如何保证同步
synchronized锁从实现上讲,是在对象(局部的对象实例或全局的类对象)的对象头中的Mark Word字段设置了锁状态与占用线程的信息,该对象隐式地扮演一个用于同步的锁的角色,称为监视器。
当synchronized锁了某个对象,这时意味着某个线程访问到了该对象,该对象的Mark Word则设置上锁并保持占用线程的信息。当线程进入到由该对象锁封锁的代码块时(实际是线程在占用CPU时即将处理该代码段)就会进行检查。
7.1.2特性
①原子性:保证线程互斥地访问同步代码块
②可见性:保证共享变量的修改及时可见
③有序性:避免重排序
④独占锁(悲观锁):JDK6之前
⑤可重入锁:锁的检查是基于每线程而非每调用,解决了子类上回溯时的死锁问题
7.1.3 优化与锁的升级过程
在JDK6之前,synchronized锁是重型锁,这意味着除了获得该对象锁的线程以外,其他竞争的线程都会进入到阻塞的状态,等到占用线程释放锁后,再唤醒等待的线程。此时的锁是独占锁(排他锁,该锁一次只能被一个线程持有,其他线程被阻塞)
在JDK6之后,引入了CAS和自旋操作,为了减少内置锁的性能消耗,synchronized锁策略进行了改变,锁状态变为了:偏向锁→轻量锁→重量锁三种阶段。
其中,CAS操作在原子变量中进行介绍,锁的升级过程可以参考这篇博客
7.1.4 基本用法
①作用在实例方法/代码块时:监视器锁即是对象实例this
②作用在静态方法/代码块时:监视器对象是Class实例(JVM在加载该类时,会创建该类的单例的类对象,该对象存储于永久代,该锁相当于全局锁)
③作用在方法/代码块但指明了作用对象时:监视器对象是括号括起来的对象实例,若为局部变量则有被回收的可能
public class InnerLockTest {
private Integer state;
// 1.局部代码块,每个对象初始化时调用
{
synchronized (this){}
}
// 2.静态代码块:类被JVM加载时调用一次
static {
// 由于这里的代码块是静态的,因而只能锁类不能锁对象,全局锁
synchronized (InnerLockTest.class){ }
}
// 3.锁成员函数,锁对象是本对象this,等价于:public void test1(){ synchronized(this){} }
public synchronized void test1(){}
// 4.锁类函数,锁对象是class对象(JVM将该类加载入虚拟机时产生唯一对象),全局锁
public static synchronized void test2(){}
public void test3(){
// 5.锁代码块,局部锁
synchronized (this){}
// 6.锁代码块,全局锁
synchronized (InnerLockTest.class){}
}
public void test4(){
// 7.锁对象实例,该对象可能会被回收
synchronized (this.state){ }
}
}
7.1.5使用关键
①对于每一个涉及多个变量的不变约束,需要同一个锁保护其所有的变量
②每个访问共享资源的代码路径都必须被加锁同步
③私有化共享资源
7.1.6实例限制与Java监视器模式
1)实例封闭:将状态变量或一个包含状态变量的非线程安全对象用另一个对象进行封装,外部对象实际上作为一个控制线程安全的锁层,限制了对内部对象/状态变量的访问必须从封装对象中进行,把实例限制同各种适当的锁策略结合,就可以确保状态变量/非线程安全对象被线程安全的访问。
2)具体做法
①将状态变量/非线程安全的对象封装在一个对象内部,访问修饰为private
②通过正确的锁策略同步所有对状态变量/非线程安全对象的访问
事实上,实例限制的实际做法,与上面介绍的synchronized锁的使用关键完全吻合。
3)Java监视器模式
Java监视器模式实际是对实例限制模式的推广,其区别在于实例限制一般是用锁层对象(封装对象)作为syncrhonized的监视器,而Java监视器模式则是使用锁层内部的私有对象作为synchronized的监视器,使用私有变量的优势则是外部无法访问到监视器对象,从而也就无法对锁策略进行修改。
实例限制和监视器模式对于显式锁仍然适用
7.2显式锁
显示锁是JDK 5.0提供的同步工具,用于对synchronized内置锁的替代和增强
7.2.1显示锁接口Lock
Lock是显示锁的接口定义了显示锁拥有的操作,相比于内置锁仅提供了无条件的加锁方式,显示锁还提供了除无条件加锁以外的可轮询的、限时的、可响应中断的加锁操作,其加锁和解锁过程都是显式的。
public interface Lock{
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long timeout,TimeUnit timeunit) throws InterruptedException;
void unlock();
Condition newCondition();
}
7.2.2可重入锁ReentrantLock
1)可重入锁ReentrantLock实现了Lock接口,提供了与内置锁相同的互斥和可见性保证,与内置锁有相同的语义。
2)使用示例
①可轮询的加锁方式避免死锁
两个账户相互转账,由各自的可重入锁保证自身的线程安全。转账时需要同时获取两把锁
public class Account {
private long account;
public ReentrantLock reentrantLock = new ReentrantLock();
public void addAccount(long num){
this.account+=num;
}
public void removeAccount(long num){
this.account-=num;
}
}
public class ReentrantLockDemo1 {
public static void main(String[] args) throws InterruptedException {
Account sendAccount = new Account();
Account receiveAccount = new Account();
long stopTime = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);// 超时时长
while (true){ // 轮询
if(sendAccount.reentrantLock.tryLock()){ // 尝试获取转帐方的锁
try {
if(receiveAccount.reentrantLock.tryLock()){ // 尝试获取收款方的锁
try { // 转账操作
sendAccount.removeAccount(100);
receiveAccount.addAccount(100);
}finally {
receiveAccount.reentrantLock.unlock();
}
}
}finally {
sendAccount.reentrantLock.unlock();
}
}
if (System.nanoTime() > stopTime) // 超时失败,退出轮询
break;
Thread.sleep(new Random().nextInt(3)*100); // 阻塞等待重新轮询,增加随机性
}
}
}
②限时锁(可响应中断)
public class ReentrantLockDemo2 {
public static void main(String[] args) throws InterruptedException {
ReentrantLock reentrantLock = new ReentrantLock();
// 未在规定时间内获得锁,则返回加锁失败,这里采用退出程序
if(! reentrantLock.tryLock(3, TimeUnit.SECONDS))
System.exit(-1);
try {
// do something.
}
finally {
reentrantLock.unlock();
}
}
}
③可响应中断的非限时锁
public class ReentrantLockDemo3 {
public static void main(String[] args) throws InterruptedException {
ReentrantLock reentrantLock = new ReentrantLock();
reentrantLock.lockInterruptibly();
try {
// do something..
}finally {
reentrantLock.unlock();
}
}
}
3)对比可重入锁与内置锁
①显示锁的优势
新增显式锁是为了弥补内置锁的一些不足,提供了更灵活的加锁方式。内置锁一旦某一线程请求加锁,该线程若未获得锁则会一直阻塞,容易造成活跃性问题,若同时请求多把内置锁则容易出现死锁问题,通过显式锁可以进行限时加锁,若在规定时间内未获取到全部的锁,则会放弃并轮询,避免了顺序死锁。
②性能对比
在JDK6之前内置锁是重型锁,其性能不如显示锁,但JDK6之后内置锁进行了优化,其性能已经与显示锁相当。
③内置锁的优势
简洁,不易出错,使用显示锁忘记在finally块解锁则会出现灾难性的错误。
④公平性
可重入锁默认同内置锁一样,都是非公平的,即抢占式的,但也支持公平的线程调度方式。非公平的调度方式比公平的调度方式性能更佳。
7.2.3读写锁接口ReadWriteLock
1)读写锁也属于显式锁,读写锁拥有读锁和写锁两种锁,对于读操作普遍比写操作更加频繁的场景下,使用读写锁可以提高性能,读写锁既可以保证多个线程同时读的效率,又可以保证写操作时的线程安全。
2)读写锁的获取规则
①如果有一个线程已经占用了读锁,则此时其他线程如果要申请读锁,可以申请成功。
②如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁,因为读写不能同时操作。
③如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,都必须等待之前的线程释放写锁,同样也因为读写不能同时,并且两个线程不应该同时写。
总的来说,要么是一个或多个线程同时有读锁,要么是一个线程有写锁,但是两者不会同时出现。也可以总结为:读读共享、其余互斥(写写互斥、读写互斥、写读互斥)
7.2.4可重入读写锁ReentrantReadWriteLock
eentrantReadWriteLock是ReadWriteLock的实现类,其使用示例如下
public class ReadWriteMap<K,V> {
private final Map<K,V> map;
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock readLock = lock.readLock();
private final Lock writeLock = lock.writeLock();
public ReadWriteMap(Map<K, V> map) {
this.map = map;
}
// 修改操作
public V put(K key,V value){
writeLock.lock();
try {
return map.put(key,value);
}finally {
writeLock.unlock();
}
}
// 读操作
public V get(K key){
readLock.lock();
try {
return map.get(key);
}finally {
readLock.unlock();
}
}
}
8.线程安全容器与委托线程安全
8.1同步容器
1)同步容器的定义
同步容器即操作同步化的容器。对同步容器的所有公共方法通过加自身的内置锁进行同步,实现线程安全。
2)分类
①JDK1.0即存在的同步容器:Vector、Hashtable
②JDK1.2加入的同步包装类:由Collections.synchronizedXXX创建的
3)缺点
①性能较差:对容器的所有状态进行串行访问,削弱了并发性
②复合操作:如迭代、根据一定顺序寻找下一个元素、以及如缺少即加入这样的复合操作仍然是线程不安全的。需要对复合操作进行同步,另外注意给迭代操作上锁并不是一个很好的选择,取而代之的是复制容器,还需要注意隐藏迭代的问题。
③快速失败机制:这种机制会报错提示ConcurrentModificationException
,一般出现在当某个线程在遍历容器时,其他线程恰好修改了这个容器的长度。它只能作为一个建议,告诉我们有并发修改异常,但是不能保证每个并发修改都会爆出这个异常。
// Vector.Itr.checkForComodification
final void checkForComodification() {
// modCount:容器的长度变化次数, expectedModCount:期望的容器的长度变化次数
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
4)使用场景
并发量不是很大的场景
①写多读少,此时同步容器与并发容器的性能差别不大(并发容器可以读并发)
②自定义的复合操作
8.2并发容器
1)并发容器的概念
并发容器作为对同步容器的改进,目的是提高并发量,并将一些同步容器中没有的常用的复合操作作为原子操作加入到并发容器中,包含在java.util.concurrent
下
2)常用的并发容器
①ConcurrentHashMap
对应于Hashtable,同步容器采用公共锁锁每一个方法,因而严格限制只有一个线程同时可以访问容器。
而ConcurrentHashMap采用分段锁,不直接对整个容器上锁,封锁粒度小。分段的依据是key.hash,根据hash值映射到不同段(默认16分段),插入数据时分段上锁,其他段仍可正常读写(不同段锁只要不冲突就可以同时写)读不上锁,通过volatile保证可见性不会读到过期值。
②CopyOnWriteArrayList
读取不加锁,写入也不阻塞读取。可变操作通过创建底层数组的新副本,在修改完成后将副本替换原有的数据(指针指向改变),并对各线程可见。
③ConcurrentLinkedQueue
非阻塞队列通过CAS非阻塞操作实现。
④BlockingQueue
阻塞队列,被广泛用于生产者-消费者问题,当队列已满插入操作阻塞,当队列已空获取操作阻塞。其实现类有ArrayBlockingQueue、LinkedBlockingQueue
、PriorityBlockingQueue
名称 | 容量可变性 | 并发控制 | 底层实现 | 公平性 |
---|---|---|---|---|
ArrayBlockingQueue | 一旦创建,容量不能改变 | 可重入锁 ReentrantLock | 数组 | 默认不能保证,欲保证需使构造函数的第二个参数为true,保证公平性会降低吞吐量 |
LinkedBlockingQueue | 可无界可有界,创建通常指定大小,不指定默认为Integer.MAX_VALUE | 单向链表 | 公平 | |
PriorityBlockingQueue | 无界队列,需初始化队列大小 | 可重入锁 ReentrantLock | ||
SynchronousQueue |
⑤Deque、BlockingDeque
Deque双端队列,实现是ArrayDeque、LinkedBlockDeque
窃取工作模式:相比于仅有一个共享队列的传统的生产者-消费者模式,窃取工作模式中每一个消费者都有一个自己的双端队列。如果一个消费者完成了自己队列中的任务,就会从其他消费者队列的末尾偷取工作,这样可以确保每一个线程都尽量保持忙碌状态。
⑥ConcurrentSkipListMap、ConcurrentSkipListSet
基于跳表实现,跳表是一种空间换时间的算法,维持多个链表,每个链表元素按值从小到大顺序排序,最下层链表是全部元素,上层链表元素均为下层链表的子集。查询元素时从上到下、从左到右的查询,不同层链表之间发生跳跃。由于其有序性,比hash索引更加快捷,时间复杂度为O(logn),比堆更容易维护。
8.3委托线程安全
1)委托线程安全是设计线程安全类的另一种设计模式,相比于基于锁和访问控制的实例限制,委托线程安全直接利用类库中已有的线程安全容器来保证线程安全。
2)注意事项
①将线程安全委托到多个状态变量上,若状态变量彼此独立,则组合并未增加任何涉及多个状态变量的不变约束
②线程安全容器中元素的线程安全性需要通过自身保证
8.4向已有的线程安全类添加功能
1)最安全的方式
修改原始的类,以支持期望的操作。不足:该已有类可能无法访问源码或没有修改权限
2)扩展这个类
// 已知Vector对象的内置锁的监视器是其自身this
public class BetterVector<E> extends Vector<E>{
public syncrhonized boolean putIfAbsent(E x){
boolean absent = !contains(x);
if(absent) add(x);
return absent;
}
}
不足:扩展后,同步策略的实现会被分不到多个独立维护的源代码文件中,所以扩展一个类比直接修改已有类更加脆弱,如果原有的类采用了不同的锁保护它的状态变量,从而会改变它的同步策略。
3)扩展功能
客户端代码不知道同步封装工厂方法返回的List对象的类型。注意要与已有类加同一把锁。
public class ListHelper<E>{
// 客户端不知道通过Collections的静态工厂方法获取对象的实际类型,只能用抽象类型承接
public List<E> list = new Collections.syncrhonizedList(new ArrayList<E>());
public boolean putIfAbsent(E x){
syncrhonized(list){
boolean absent = !contains(x);
if(absent) add(x);
return absent;
}
}
}
4)组合
相比于尝试模拟另一个对象的锁策略,是更健壮的选择,实现一个List,客户端仅通过外层对象对其进行访问。
public class ImprovedList<T> implements List<T>{
private final List<T> list;
public ImprovedList(List<T> list){this.list = list;}
public synchronized boolean putIfAbsent(T x){
boolean absent = !contains(x);
if(absent) add(x);
return absent;
}
// implements List methods
}
9.原子变量与非阻塞同步机制
9.1voliate变量
将voliate变量放在此处进行介绍,未免有些为时过晚。由此可见,想搭建清晰、系统的知识体系并非易事,无论是以时间为主序还是以主题为主序划分章节都会产生一些不妥之处,修行永远在路上。。
1)介绍
voliate是JDK 5.0就出现的可见性工具,volatile关键字用于修饰变量,与锁相比它是更轻量的同步机制,它们不会引起上下文的切换和线程调度。
2)特点
①保证可见性:会将本地内存中的变量修改强制刷新到主内存中,写操作会使其他线程的voliatle变量无效
②禁止指令重排序:在voliatle之前的读写操作全部完成,且对后面可见,在其后面的操作肯定没有执行
③不完全保证原子性:写入变量不能依赖变量的当前值;确保只有单一的线程可以修改变量的值;不能保证组合安全
3)使用场景
确保修饰变量的可见性,如检查标记状态,判断是否线程退出一个循环
9.2非阻塞算法与CAS操作
1)非阻塞和锁自由
①非阻塞:一个线程的失败或挂起不影响其他线程的失败或挂起
②锁自由:若算法的每一步骤都有一些线程能够继续执行
2)非阻塞算法
非阻塞算法使用低层原子化的机器指令取代锁,如比较并交换CAS、测试并设置TAS、获取并增加FAI和加载链接/存储条件,从而保证多线程在无阻塞的情况下保证并发访问的一致性,减少性能开销,对死锁和其他活跃度问题具有免疫性。
3)CAS操作
①三个操作数:内存位置A、旧的预期值O、新值N
②思路:采用乐观锁取代悲观锁(独占锁),此方法依赖于冲突检测,判定更新过程中是否有其他线程的干扰,在冲突发生时操作失败,并重试(也可能不重试)。
③过程:当旧的预期值O符合内存位置A的值时,CAS用新值N原子化地更新A,否则它什么都不做,无论更新是否成功,CAS操作都会返回V的真实值。当多个线程用CAS同时更新变量时,其中一个会胜出,并更新变量的值,其余线程会失败,失败的线程不会被挂起,而是重新尝试(或什么都不做)
public class CasDemo {
private volatile int value = 0;
public int get(){
return value;
}
public int compareAndSwap(int expectedValue,int newValue){
int oldValue = value;
if(oldValue == expectedValue)
value = newValue;
return oldValue;
}
public static void main(String[] args) {
CasDemo casDemo = new CasDemo();
// CAS操作前获取预期值
int expectedValue = casDemo.get();
// 在更新过程后校验更新时的内存实际值与预期值是否一致,来判断冲突,冲突则重新尝试,否则表明在该更新过程中未发生冲突,更新成功
while (expectedValue != casDemo.compareAndSwap(expectedValue,10));
System.out.println(10);
}
}
④JVM对CAS的支持
JDK5.0之后引入了对CAS的支持,将int、long和对象的引用暴露给CAS操作,在CAS指令不可用的情况下,JVM会使用自旋锁,这些对CAS的支持操作,用于原子化变量类,使用这些原子变量类可以构建出非阻塞同步算法。
9.3原子变量
1)原子变量比锁更轻量、更精巧,是更佳的volatile变量,同时保证了同步和可见性,并提供了更多的同步复合操作。
更新原子变量的非竞争路径,不会引起线程的挂起和重新调度,线程响应性更好,在一般情况下的性能由于锁。但在竞争非常激烈下,性能会劣于锁。
2)原子变量类
①基本类型:AtomicBoolean、AtomicInteger、AtomicLong
后两者支持算数运算,对于short、byte,将其强转为int,对于浮点型,使用floatToIntBits和doubleToLongBits
public class AtomicIntegerDemo {
private static AtomicInteger atomicInteger = new AtomicInteger(0);
public static void main(String[] args) {
for(int i=0;i<10;i++)
new Thread(()->{
for(int j=0;j<100;j++)
System.out.println(Thread.currentThread().getId() + ":\t" + atomicInteger.getAndIncrement());
}).start();
}
}
②数组:AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
原子化的数组的元素都是被原子化的更新
public class AtomicIntegerArrayDemo {
private static int[] values = new int[10];
// 原子数组构造时使用的初始化数组,是原数组的复制体,其修改不会对原数组进行修改
private static AtomicIntegerArray atomicIntegerArray = new AtomicIntegerArray(values);
public static void main(String[] args) {
for(int i=0;i<10;i++)
new Thread(()->{
for(int j=0;j<10;j++)
for(int k=0;k<100;k++)
System.out.println(Thread.currentThread().getId() + "-" + j + ":\t" + atomicIntegerArray.getAndIncrement(j));
}).start();
}
}
③引用:AtomicReference、AtomicMarkableReference、AtomicStampedReference
后两者用于解决ABA问题:即将预期值与当前变量的值比较的时候,即使相等也不能保证变量没有被修改过,因为变量可能由A变成B再变回A,解决该问题,可以给变量增加一个版本号,每次修改变量时版本号自增,比较的时候,同时比较变量的值和版本号即可
public class AtomicReferenceDemo {
private static class IntPair{
final int lower;
final int upper;
private IntPair(int lower, int upper) {
this.lower = lower;
this.upper = upper;
}
}
AtomicReference<IntPair> atomicReference = new AtomicReference<>(new IntPair(0,0));
public void setLower(int lower){
while (true){
IntPair oldIntPair = atomicReference.get();
if(lower > oldIntPair.upper)
throw new IllegalArgumentException("the lower can't be larger than original upper");
IntPair newIntPair = new IntPair(lower,oldIntPair.upper);
if(atomicReference.compareAndSet(oldIntPair,newIntPair))
break;
}
}
}
④更新引用中的域(成员变量):AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceUpdater
代表着已存在的volatile域基于反射的视图,使得CAS能够用于已有的volatile域
域更新器的原子性保护比普通原子类差一些,因为无法保证被封装的域不会被直接修改(不通过原子变量),原子性只能在使用原子变量的更新操作时得到保证
public class AtomicReferenceFieldUpdaterDemo {
private static class User{
String name;
volatile int age;
User(String name,int age){
this.name = name;
this.age = age;
}
}
private static AtomicIntegerFieldUpdater<User> atomic = AtomicIntegerFieldUpdater.newUpdater(User.class,"age");
public static void main(String[] args) {
User user = new User("李一帆",22);
for(int i=0;i<10;i++)
new Thread(()->{
for(int j=0;j<2;j++)
System.out.println(Thread.currentThread().getId() + ":\t" +
atomic.getAndIncrement(user));
}).start();
}
}
10.多线程任务与线程池
10.1任务的执行
1)多线程任务
多线程任务需要明确一个清晰的任务边界,理想情况下,任务是独立的活动(不依赖于其他任务的状态、结果)。大多数服务器程序都选择了一个自然的任务边界:单独的客户请求。
2)大量相互独立的同类任务进行并发处理,可以显著提高程序的性能(如上述的请求处理)异类任务进行并发处理,有时由于不同任务之间的执行速度不匹配,可能导致程序的性能无法显著提升,且带来更大的编码复杂度和安全性问题。
3)要求:服务器应用程序应该兼具良好的吞吐量和快速的响应性,在负载时应平缓地劣化而非崩溃。
4)示例:服务器响应客户请求
①单一线程串行处理请求
弱并发,资源利用率低,响应率低
while(true){
Socket connection = socket.accept();
handleRequest(conncection);
}
②每请求每线程
提高了并发量和响应速度,无限制创建线程的缺点:线程生命周期的开销、内存消耗量不限制导致崩溃、给垃圾回收增加压力。
while(true){
Socket conncetion = socket.accept();
new Thread(()->{handleRequest(connection);}).start();
}
③基于池的策略
private static final Executor exec = new Executors.newFixedThradPool(50);
while(true){
Socket connection = socket.accept();
exec.execute(new Runnable(){
handleRequest(connection);
})
}
10.2线程池
10.2.1线程池的优势
Executor接口使得任务的提交和任务的执行进行了解耦,有利于任务执行代码的可维护性,任务执行时无需新线程创建的消耗可以直接复用已有线程,提高了响应性,且避免了无限制的线程创建导致的资源消耗。
10.2.2线程池的使用场景
1)线程池适用于同类的、独立的任务
2)线程池不适用的场合
①依赖性任务:提交给线程池的任务依赖于其他任务,需要避免活跃度问题,如线程饥饿死锁问题
线程饥饿死锁:若线程池的容量有限且较小,向线程池提交一个任务,而该任务依赖于其他任务,并将其他任务也提交至线程池,依赖任务可能由于线程池无容量而等待,而当前占用线程池容量的线程需要等待依赖线程执行,因而造成了死锁。
②采用线程限制的任务:一些任务由线程封闭而保证线程安全,如ThreadLocal保护的变量,但由于线程池复用共享了运行线程,则会导致线程封闭失效,带来安全性问题。
③耗时与对响应性敏感的任务:耗时任务会造成线程池堵塞,延长响应时间,通过限时方法限定任务的等待时间和增加线程池容量可以缓解这一问题
10.2.3线程池的生命周期
Executor接口未提供对线程池生命周期管理接口的描述,ExecutorService接口扩展了Executor并添加了一些用于管理生命周期的方法和一些便于任务提交的方法。
public interface ExecutorService extends Executor{
void shutdown();
List<Runnable> shutdownNow();
boolean isShutdown();
boolean isTerminated();
boolean awaitTermination(long timeout,TimeUnit unit) throw InterruptedException;
// other satisfied methods for task submiting...
}
1)线程池的生命周期
①运行状态:ExecutorService创建后进入运行阶段
②终止状态:一旦所有任务执行完后会进入到终止状态,可以调用awaitTermination方法等待ExecutorService到达终止状态,或通过isTerminated进行轮询。通常shutdown会紧随awaitTermination之后,产生同步关闭ExecutorService的效果
③关闭状态:shutdown会启动平滑关闭的过程,停止接受新任务,等待已提交任务的完成(包括未开始执行的任务)shutdownNow会启动强制关闭的过程,尝试取消所有运行中的任务和排队等待的任务。
进入到关闭状态后,提交到ExecutorService中的任务会被拒绝执行处理器处理,拒绝执行处理器是ExecutorService的一种实现,处理方式一般是简单地放弃任务或抛出RejectedExecutionException异常
2)线程池中任务的生命周期:创建→提交→开始→完成
10.2.4线程池的实现类
JDK提供了Executor的预设的4种实现类,可以通过Executors中的静态工厂方法创建
名称 | 解释 |
---|---|
newFixedThreadPool | 创建定长线程池,如果一个线程由于异常而结束,线程池会补充一个线程 |
newCachedThreadPool | 创建可缓存的线程池,任务增加则新增线程,对线程数不做限制,若线程数超过预设值,则回收空闲线程 |
newSingleThreadExecutor | 创建单线程化的线程池,若唯一线程异常结束则会创建新的替换它,可以保证任务顺序(FIFO、LIFO、优先级) |
newScheduledThreadPool | 创建定长线程池,支持定时的周期任务,是Timer的良好替代 |
10.2.5基于已有线程池实现类的使用示例
1)ScheduledThreadPool
执行延迟的、周期性的任务
①Timer类的缺陷
Timer类可以让任务延迟timer.schedule或周期性timer.scheduleAtFixedRate的执行,但Timer类具有一些缺陷:
- 只创建单一线程执行该timer对象的所有任务
- 一个耗时的timer任务会导致其他的任务时效准确性不准
- 线程泄露:timer不处理异常,一个timertask任务抛出异常会导致该timer终止
②ScheduledThreadPool使用示例
public class ScheduleThreadPoolDemo {
public static void main(String[] args) throws InterruptedException {
ScheduledExecutorService executor = Executors.newScheduledThreadPool(10);
executor.schedule(new Runnable() {
@Override
public void run() {
System.out.println("delay one second");
}
},1, TimeUnit.SECONDS);
executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
System.out.println("run every one second");
}
}, 0, 1, TimeUnit.SECONDS);
Thread.sleep(5000);
executor.shutdown();
}
}
2)携带返回值的任务
public class ExecutorServiceDemo {
public static void main(String[] args) throws ExecutionException, InterruptedException {
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int count = 0;
for(int i=0;i<500;i++)
count++;
return count;
}
});
Integer result = future.get(1,TimeUnit.NANOSECONDS);
System.out.println(result);
executor.shutdown();
}
}
3)逐个获取批量任务结果
①ExecutorCompletionService
ExecutorCompletionService实现了CompletionService接口,是Executor和BlockingQueue的结合
- take:有完成的任务则返回,否则阻塞到任务完成
- poll:无参的poll有完成的任务则返回,没有则返回null;有参的poll有完成的任务则返回,没有则阻塞一段时间还没有完成的任务则返回null
②示例
public class ExecutorCompletionServiceDemo {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executor = Executors.newFixedThreadPool(3);
ExecutorCompletionService<Integer> executorCompletionService = new ExecutorCompletionService<>(executor);
for(int i=0;i<10;i++){
int finalI = i;
executorCompletionService.submit(new Callable<Integer>() {
@Override
public Integer call() {
return finalI;
}
});
}
for(int i=0;i<10;i++){
Future<Integer> result = executorCompletionService.take();
System.out.println(result.get());
}
executor.shutdown();
}
}
4)同步获取批量任务结果
使用ExecutorService的invokeAll处理批异步任务,待全部完成时返回结果。限时的invokeAll到时间后未完成的任务全部会被取消,客户端代码可以通过get或inCancelled查看对应任务是完成了还是取消了
public class InvokeAllDemo {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executor = Executors.newFixedThreadPool(3);
List<Callable<Integer>> task = new ArrayList<>();
for(int i=0;i<10;i++){
final int finalI = i;
task.add(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
return finalI;
}
});
}
List<Future<Integer>> futures = executor.invokeAll(task,1, TimeUnit.SECONDS);
for(Future<Integer> future :futures){
Integer result = future.get();
System.out.println(result);
}
executor.shutdown();
}
}
10.2.6自定义线程池
1)构造函数
public ThreadPoolExecutor(int corePoolSize,
int maxPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler){...}
2)定制线程池的大小
线程池的容量等于corePoolSize的大小,maxPoolSize是线程池容量的上限,若一个线程已经闲置超过了存活时间,它将成为一个被回收的候选者,若当前线程池的容量大小超过了corePoolSize的大小,它则会被回收
①线程池合理的容量取决于任务类型和计算机性能,线程池的最佳容量难以精确计算,不过避免过大、过小即可,可以通过多次实验寻找合适的超参数。
②经验容量
CPU核数:
N
c
p
u
N_{cpu}
Ncpu 可用的CPU核数可以通过Runtime.getRuntime().availableProcessors()
动态获取
目标CPU的利用率:
U
c
p
u
∈
[
0
,
1
]
U_{cpu}∈[0,1]
Ucpu∈[0,1]
等待与计算时间的比例:W/C
任务类型 | 容量数 | 解释 |
---|---|---|
计算密集型 | N c p u + 1 N_{cpu}+1 Ncpu+1 | 设置一个备胎,便于在某一线程停止工作时快速补充 |
包含阻塞操作,如IO | N c p u × U c p u × ( 1 + W / C ) N_{cpu}×U_{cpu}×(1+W/C) Ncpu×Ucpu×(1+W/C) | 需要更大的线程池 |
受其他资源限制,如DB连接池容量 | 上限:资源总量 / 单次线程所需资源数 |
3)管理队列任务
任务排队有三种基本方法:
①无限队列:存在资源耗尽的风险
②有限队列:稳妥的资源管理策略,但会引发饱和问题,即提交的任务数超过了有限队列长度要如何处理
③同步移交:若线程池的容量很大或无限,则可以使用SynchronousQueue
将任务直接从生产者移交给工作线程,SynchronousQueue
不是一个真正的队列,而是一种管理直接在线程间移交信息的机制,为了将一个元素放入SynchronousQueue
中,必须有另一个线程正在等待移交的任务,若没有这样一个线程,线程池则会新建线程或触发饱和策略。
LinkedBlockingQueue和ArrayBlockingQueue这种FIFO的队列会使得任务顺序执行,PriorityBlockingQueue可以通过优先级执行任务
4)制定饱和策略
饱和策略:是指提交的任务数超过等待队列的容量或向一个已经关闭的线程池提交任务时触发的响应机制
①AbortPolicy:默认的中止策略,会抛出RejectedExecutionException异常
②DiscardPolicy:丢弃这个超过队列容量的任务
③DiscardOldestPolicy:丢弃最先进入的或优先级最高的任务
④CallerRunsPolicy:调用者运行策略,会将任务返回给提交任务的线程进行执行
5)自定义线程工厂
ThreadFactory只有newThread一个方法,它会在线程池需要创建新线程时调用,默认的线程工厂创建一个新的、非后台的线程。
自定义线程工厂实现ThreadFactory接口可以对生成的线程进行配置,如设置名称,打印日志,设置自定义的UncaughtExceptionHandler等
如果想让实际工作的线程和线程工厂创建的线程有相同的权限、AccessControlContext和contextClassLoader则需要使用privilegedThreadFactory,否则实际工作的线程则会继承提交任务时的线程
6)配置线程池的生命周期
扩展ThreadPoolExecutor重写其钩子函数,可以在钩子函数中做添加日志、监视器或统计收集信息的工作
钩子函数名称 | 触发规则 |
---|---|
beforeExecute | 任务线程执行前执行,若在beforeExecute中触发异常,则任务和afterExecute都不会执行 |
afterExecute | 任务线程正常执行后或抛出异常时执行,若抛出的是Error则不会执行 |
terminated | 线程池关闭后执行 |
7)线程池重设与不可变封装
①线程池在构造函数完成后,可以通过setter函数对已有线程池重新设置
②如果不想让发布了的线程池被用户修改,则可以通过unconfigurableExecutorService包装线程池,它无法对线程池再进行配置
8)自定义线程池使用示例
public class MyThreadPoolExecutor extends ThreadPoolExecutor{
public MyThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,RejectedExecutionHandler handler) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,threadFactory,handler);
}
@Override
protected void beforeExecute(Thread t, Runnable r) {
super.beforeExecute(t, r);
System.out.println(t.getId() + "has running");
}
@Override
protected void afterExecute(Runnable r, Throwable t) {
super.afterExecute(r, t);
System.out.println(r.toString() + "has finished");
}
@Override
protected void terminated() {
super.terminated();
System.out.println("My Executor has shutdown.");
}
public static ExecutorService getMyExecutor(){
int availableCPUCount = Runtime.getRuntime().availableProcessors();
System.out.println("available cpu counts:" + availableCPUCount);
int coreThreadCount = availableCPUCount + 1;
int maxThreadCount = coreThreadCount;
long keepAliveTime = 1;
TimeUnit timeUnit = TimeUnit.MINUTES;
LinkedBlockingDeque<Runnable> blockingDeque = new LinkedBlockingDeque<>(coreThreadCount);
ThreadFactory threadFactory = Executors.privilegedThreadFactory();
RejectedExecutionHandler rejectedExecutionHandler = new ThreadPoolExecutor.DiscardPolicy();
MyThreadPoolExecutor myThreadPoolExecutor = new MyThreadPoolExecutor(coreThreadCount,maxThreadCount,keepAliveTime,timeUnit,blockingDeque,threadFactory,rejectedExecutionHandler);
return Executors.unconfigurableExecutorService(myThreadPoolExecutor);
}
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executorService = MyThreadPoolExecutor.getMyExecutor();
List<Callable<Integer>> tasks = new ArrayList<>();
for(int i=0;i<20;i++){
Callable<Integer> task = () -> {
Integer random = new Random().nextInt(100);
int count = 0;
while (count < random)
count++;
return count;
};
tasks.add(task);
}
List<Future<Integer>> futures = executorService.invokeAll(tasks,5,TimeUnit.SECONDS);
for(Future<Integer> future:futures){
try {
Integer result = future.get();
System.out.println(result);
}catch (CancellationException cancellationException){
System.out.println("task canceled.");
}
}
executorService.shutdown();
}
}
10.3任务的取消
1)任务的取消是指:在任务或线程自然结束之前提前停止它们,Java语言中没有提供明确的取消一个线程的语法,没有哪一种停止线程的方式是绝对安全的。
能否处理好失败、取消和关闭是一个好的应用程序与勉强能用的应用程序的区别。
2)取消策略的概念
取消策略是指其他代码如何取消该线程/任务。
3)通过应用程序取消一个线程
public class CancelThread implements Runnable{
private volatile boolean canceled = false;
@Override
public void run() {
int count = 0;
while (! canceled){
System.out.println(count);
count++;
}
}
public void cancel(){
this.canceled = true;
}
public static void main(String[] args) throws InterruptedException {
CancelThread cancelThread = new CancelThread();
new Thread(cancelThread).start();
Thread.sleep(1);
cancelThread.cancel();
}
}
这种做法会产生一个问题,当由状态变量canceled控制的逻辑代码出现阻塞时,canceled条件可能一直得不到检查,而导致无法及时控制关闭。
4)通过中断机制取消
中断是实现取消最明智的选择
①如何通过中断取消任务
每一个线程都有一个boolean类型的中断状态,当线程在非阻塞状态下中断时,该中断状态会被设置为true,然后一直保持中断状态。在线程的一些阻塞方法中,线程会抛出InterruptedException
异常(或其他异常),一些不响应中断的阻塞方法需要特殊处理。
-
Thread类中的interrupt方法可以中断目标线程:它仅仅传递了中断请求,线程自己会在何时的时刻(取消点)进行中断。立即结束一个线程可能会导致数据的不一致而发生问题。
-
Thread类中的isInterrupted方法返回线程当前的中断状态
-
Thread类中静态的Interrupted方法仅仅能够清除当前线程的中断状态,并返回它之前的值,这是清除线程中断状态的唯一方法。
②制定中断策略
中断策略:决定一个线程应该如何响应中断请求
如果使用中断作为取消策略,则必须知道目标线程的中断策略,不要中断一个没有中断策略的线程。
中断策略通常包括:
- 抛出异常(让使用中断的方法也变成可中断的阻塞方法)
- 保存中断状态
在InterruptedException异常捕获后保存中断信息,再次调用interrupt方法来取消该任务
③示例
public class InterruptCancelThread extends Thread{
LinkedBlockingDeque<Integer> blockingDeque = new LinkedBlockingDeque<>();
@Override
public void run() {
int count = 0;
try {
while (! Thread.currentThread().isInterrupted()){
System.out.println(count);
blockingDeque.put(count);
count++;
}
} catch (InterruptedException e){
cancel();
}
}
public void cancel(){this.interrupt();}
public static void main(String[] args) throws InterruptedException {
InterruptCancelThread interruptCancelThread = new InterruptCancelThread();
interruptCancelThread.start();
Thread.sleep(1);
interruptCancelThread.cancel();
}
}
5)通过ExecutorService、FutureTask框架取消线程
Future.cancel方法可以取消任务
10.4关闭JVM
1)JVM正常关闭的流程
启动所有注册的关闭钩子(不保证顺序) → 关闭所有线程,若关闭线程时关闭钩子仍在执行,则这两者将并发执行 → 当所有钩子关闭了时,若runFinalzersOnExit为true,JVM则可以选择运行finalizer → JVM停止
JVM不会尝试停止任何关闭时仍在运行的线程,这些线程将在JVM停止时被强制退出;
如果关闭钩子或者finalizer没有完成,那么正常关闭的线程将挂起,且JVM必须强行关闭,强行关闭时,JVM无需完成除关闭JVM以外的任何事情,如关闭钩子
2)关闭钩子
关闭钩子必须是线程安全的,它们在访问共享数据时必须同步,并小心避免死锁。
Runtime.getRuntime().addShutdownHook(new Thread(){
@Override
public void run() {
// do something...
}
});
3)守护线程:守护线程应该小心使用,最好用于”家务管理“的任务
4)避免使用finalizer
11.协调线程的执行
书中对本章的描述如下,构建依赖于状态的类,即这些类拥有基于状态的先验条件来抽象地进行描述。简单地说,线程的执行依赖于某个条件,若该条件无法得到满足,则线程需要等待,这时就需要一种协调/同步机制来使该条件满足时,线程开始执行。
11.1低效的协调方法
1)笨拙的的条件等待方法是使用忙等待(自旋等待)或循环阻塞的方式
2)一种折中的方式是使用Thread.yield
,即给调度器一个指示,我现在可以让出一定时间给其他线程执行。
这两者无疑增加了性能消耗和低响应率和再次失败的概率。
11.2条件队列
条件队列的元素是等待相关条件的线程,它比循环阻塞的方法更高效,在CPU效率、上下文切换开销、响应性方面更佳。
11.2.1重要原则
使用锁和条件队列,无论是内置的还是显式的,都遵循一个重要的原则:管理状态的独立性必须紧密依赖于确保状态一致性,即条件队列与锁紧密关联,条件队列归属于某一对象,内部条件队列归属于某一Object对象,显式条件队列归属于某一Condition对象,而调用条件队列的方法时必须持有该条件队列所属对象的锁,因而等待基于状态的条件与维护状态一致性紧密关联在一起
11.2.2内置条件队列
1)内置条件队列是隐式的,Object中的wait、notify、notifyAll方法构成了内置条件队列的API
①Object.wait会自动释放锁,并请求OS挂起当前线程,让其他线程获得该锁并进行封锁资源的访问操作,当该线程被唤醒时,它将重获锁
②Oject.notifyAll则是通知该对象内置条件队列中的等待线程们,全部唤醒并尝试获取锁进行相应操作
③Object.notify是通知该对象内置条件队列中的其中一个等待线程,唤醒它尝试获取锁并进行相应操作
2)内置条件队列的使用范式
①正确识别出对象可以等待的条件谓词,它是先验条件的第一站,在操作许可和状态之间建立起依赖关系
②将条件谓词和与之关联的条件队列,以及在条件队列中的等待操作,都写入文档
③无论何时,当线程在等待一个条件,一定要确保有其他线程会在条件谓词为真时通知该线程
void stateDependentMethod() throws InterruptedException{
// 1.条件谓词由锁保护
synchronized(lock){
// 2.条件检查与线程阻塞
while(! conditiionPredicate())
lock.wait();
// 3.do something...
// 4.执行结束,通知其他线程,并快速释放锁
lock.notifyAll();
}
}
3)示例:使用内置条件队列实现有限缓存队列
public class BoundedBuffer<V>{
private int count;
private final V[] buffer;
public BoundedBuffer(int capacity) {
this.count = 0;
this.buffer = (V[]) new Object[capacity];
}
public synchronized final boolean isFull(){
return count == buffer.length;
}
public synchronized final boolean isEmpty(){
return count == 0;
}
public synchronized final void put(V item) throws InterruptedException {
while (isFull())
wait();
count++;
buffer[count] = item;
notifyAll();
}
public synchronized final V get() throws InterruptedException {
while (isEmpty())
wait();
V result = buffer[0];
count--;
for(int i=0;i<count-1;i++){
buffer[i] = buffer[i+1];
}
notifyAll();
return result;
}
}
4)子类安全问题和封装条件队列
①一个依赖于状态的类,要么完全将它的等待和通知协议暴露给子类,要么完全阻止子类参与其中。否则,基类的等待和通知协议可能会被子类破坏而引发安全问题
②将条件队列封装起来,这样在它的类层结构外是无法访问到它的,不过这条建议——用封装的状态对象(缓存对象)作锁和条件队列并不常用
5)notiifyAll和notify的选择,及内置条件队列的问题
①性能方面考虑
notifyAll会唤醒该条件队列中的所有线程进行锁竞争,但最终只有一个线程可以获得运行的权力,其余线程在尝试后仍然恢复到阻塞的状态,线程的上下文切换和尝试运行无疑增加了性能开销,从这一点上,notify只从所有等待线程中选择一个使其尝试运行,可以避免这些开销。
②正确性方面考虑
不过内置条件队列的问题在于,一个内置条件队列会将所有不同条件谓词(等待条件)的线程都加入到同一个条件队列中,这时当某一谓词条件满足要求时,若仅通知某一个等待的线程,该谓词条件和该等待线程所需要的谓词条件可能不一致,这意味着该线程被过早的唤醒,仍无法执行。
普遍认可的做法是优先使用notifyAll,而不是notify,这样可以确保类的行为是正确的,对于一个内置条件队列仅包含一个谓词条件,以及一次通知至多只能激活一个线程的需求的情况下才使用notify
11.2.3显式条件队列
1)显式条件队列的优点
①显式条件队列弥补了内置条件队列的上述问题,一个显式条件队列对应一个谓词条件,一把显式锁可以对应多个显式条件队列。
②它除了提供无限制的等待还提供了限时的、可阻塞的、公平/非公平(公平性默认与显式锁的公平性保持一致)的等待
2)显式条件队列接口Condition
public interface Condition{
// 对应wait()
void awit() throws InterruptedException;
boolean await(long time,TimeUnit unit) throws InterruptedException;
long awaitNanos(long nanosTimeout) throws InterruptedException;
void awaitUninterruptibly();
boolean awaitUntil(Date deadline) throws InterruptedException;
// 对应notify()
void signal();
// 对应notifyAll()
void signalAll();
}
3)使用示例
由于显式条件队列可以与谓词条件有一对一的关系,因而使用signal更加高效
public class ConditionBoundedBuffer<V> {
private int count;
private final V [] buffer;
ReentrantLock lock;
private Condition emptyCondition;
private Condition fullCondition;
public ConditionBoundedBuffer(int capacity) {
count = 0;
buffer = (V[]) new Object[capacity];
lock = new ReentrantLock();
emptyCondition = lock.newCondition();
fullCondition = lock.newCondition();
}
public boolean isFull(){
lock.lock();
try {
return count == buffer.length;
}finally {
lock.unlock();
}
}
public boolean isEmpty(){
lock.lock();
try {
return count == 0;
}finally {
lock.unlock();
}
}
public void put(V item) throws InterruptedException {
lock.lock();
try {
while (isFull())
fullCondition.await();
count++;
buffer[count] = item;
emptyCondition.signal();
}finally {
lock.unlock();
}
}
public V take() throws InterruptedException {
lock.lock();
try {
while (isEmpty())
emptyCondition.await();
count--;
V result = buffer[0];
for(int i=0;i<count-1;i++){
buffer[i] = buffer[i+1];
}
fullCondition.signal();
return result;
}finally {
lock.unlock();
}
}
}
11.3同步工具Synchronizer
除了通过使用条件队列来手动地设计线程协调规则,使用Java类库现成的同步工具来协调线程是更好的选择。
11.3.1闭锁Latch
用于控制线程的启动时间,直到某一时刻前被控制的线程不会开始工作
public class Latch {
public static void main(String[] args) throws InterruptedException {
final CountDownLatch gate = new CountDownLatch(10);
Thread t = new Thread(()->{
try {
gate.await();
System.out.println("Sub-Thread has been started.");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t.start();
for(int i=0;i<10;i++){
System.out.println(i);
gate.countDown();
}
}
}
11.3.2FutureTask
1)FutureTask用于获取异步任务的返回值
public class FutureTaskDemo {
public static void main(String[] args) throws ExecutionException, InterruptedException {
FutureTask<Integer> futureTask = new FutureTask<>(() -> {
int count = 0;
for(int i=0;i<1000;i++){
count++;
}
return count;
});
new Thread(futureTask).start();
Integer result = futureTask.get();
System.out.println(result);
}
}
2)利用FutureTask构建高并发的缓存
服务端应用程序在一些计算操作/IO操作为了避免重复计算,往往采用缓存这种空间换时间/算力的方式
①低效的缓存:用非线程安全的容器HashMap,通过内置锁保证线程安全。通过合理缩小内置锁的范围,提高一定并发量
②更高效的缓存:通过高并发容器ConcurrentHashMap提高并发量
③更更高效的缓存:将Map<number,result>改为Map<number,FutureTask>缓存任务而非结果,这样正在计算的线程就可以被其他线程感知,更加提高了并发量。另外,需要注意缓存污染问题,在FutureTask失败时进行清除,在缓存过期时进行刷新。
11.3.3信号量
信号量用于控制能够同时访问某一资源的活动数量或控制同时执行某一操作的数量,可以用来实现资源池或给一个容器限定边界。
信号量类Semaphore管理一个数量限制,构造函数传入初始量,acquire方法可以消费一个数量,若没有可以消费的数量则会阻塞到有可用的为止。release方法可以归还一个数量
// 控制异步任务最多只有5个可以同时执行,由信号量控制,分派10个异步任务,一起开始执行,计算获取到信号量时的时间
public class SemaphoreDemo {
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(5);
CountDownLatch gate = new CountDownLatch(1);
for(int i=0;i<10;i++){
new Thread(()->{
try {
gate.await();
long start = System.nanoTime();
semaphore.acquire();
long end = System.nanoTime();
System.out.println(Thread.currentThread().getName()+ ":" + (end - start));
for(int j=0;j<1000;j++){
}
semaphore.release();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
},String.valueOf(i)).start();
}
gate.countDown();
}
}
11.3.4关卡
关卡用于控制一组线程(异步事件)同时结束,先结束的线程会进行阻塞等待其余线程共同结束,它比于控制一组线程共同的启动时间的门锁而言,可以重复使用,在并行迭代中很有用。
1)CyclicBarrier
可用于同步一组异步事件,启动下来流程的前提需要之前的所有异步事件都结束才行,如并行计算
// 计算3!+4!+5!,三组线程分别计算3!、4!、5!,最终进行合并。需要外部的final修饰的引用型变量存储计算值
public class CyclicBarrierDemo {
public static void main(String[] args) {
final Integer[] result = new Integer[]{1,1,1};
CyclicBarrier cyclicBarrier = new CyclicBarrier(3, () -> {
int finalResult = 0;
for(int i=0;i<3;i++){
System.out.println(result[i]);
finalResult+=result[i];
}
System.out.println(finalResult);
});
Runnable factorial3 = () -> {
for(int i=1;i<=3;i++)
result[0]*=i;
try {
System.out.println("3的阶乘计算完毕,等待合并计算");
cyclicBarrier.await();
} catch (InterruptedException | BrokenBarrierException e) {
throw new RuntimeException(e);
}
};
Runnable factorial4 = () -> {
for(int i=1;i<=4;i++)
result[1]*=i;
try {
System.out.println("4的阶乘计算完毕,等待合并计算");
cyclicBarrier.await();
} catch (InterruptedException | BrokenBarrierException e) {
throw new RuntimeException(e);
}
};
Runnable factorial5 = () -> {
for(int i=1;i<=5;i++)
result[2]*=i;
try {
System.out.println("5的阶乘计算完毕,等待合并计算");
cyclicBarrier.await();
} catch (InterruptedException | BrokenBarrierException e) {
throw new RuntimeException(e);
}
};
new Thread(factorial3).start();
new Thread(factorial4).start();
new Thread(factorial5).start();
}
}
2)Exchanger
Exchanger用于进行线程间的数据交换。它提供一个同步点,在这个同步点两个线程可以交换彼此的数据。这两个线程通过exchange方法交换数据, 如果第一个线程先执行exchange方法,它会一直等待第二个线程也执行exchange,当两个线程都到达同步点时,这两个线程就可以交换数据,将本线程生产出来的数据传递给对方。因此使用Exchanger的重点是成对的线程使用exchange()方法,当有一对线程达到了同步点,就会进行交换数据。因此该工具类的线程对象是成对的。
Exchanger类提供了两个方法,String exchange(V x)
用于交换,启动交换并等待另一个线程调用exchange;String exchange(V x,long timeout,TimeUnit unit)
用于交换,启动交换并等待另一个线程调用exchange,并且设置最大等待时间,当等待时间超过timeout便停止等待。
// 循环迭代,一个线程以10为底,计算其幂,作分子;另一线程计算2为底,计算其幂,作分母.通过exchanger同步,计算商
public class ExchangerDemo {
public static void main(String[] args) {
Exchanger<Double> exchanger = new Exchanger<>();
Runnable base10 = () -> {
for(int i=0;i<5;i++){
double up = Math.pow(10,i);
System.out.println("base10:" + up);
try {
Double down = exchanger.exchange(up);
System.out.println(i + ":" + (up / down));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
};
Runnable base2 = () -> {
for(int i=0;i<5;i++){
double down = Math.pow(2,i);
System.out.println("base2:" +down);
try {
Double up = exchanger.exchange(down);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
};
new Thread(base10).start();
new Thread(base2).start();
}
}
11.4自定义同步工具AbstractQueuedSynchronized
1)AbstractQueuedSynchronized
(AQS)基类是一个用于构建锁和同步器的框架,如可重入锁、信号量、闭锁、读写锁、同步队列和FutureTask等都是基于它实现的
2)它们实现时都采用了模板方法:即在锁/同步器中定义一个继承于AQS的内部类,来重写资源获取和释放的方法,再在锁/同步器中调用子类的方法(子类会调用继承重写的方法)来实现锁/同步器(组合的方式)
3)AQS定义了两种资源共享的方式:独占式Exclusive,如可重入锁;共享式Share,如信号量,闭锁,读写锁,关卡
4)数据结构
①AQS维护了一个volatile int state的整形状态变量,如信号量用其表示许可数,可重入锁用其表示线程已经请求了多少次锁,FutureTask用其表示任务状态
②和一个FIFO的CLH队列,CLH队列是一个虚拟的双向队列,即不存在队列实例,仅保存节点之间的关系,每一个节点是阻塞的线程
5)其基本流程是:如果状态变量空闲,则将当前的线程设置为有效线程,并将状态变量设置为锁定状态。若状态变量被占用,则请求线程通过自旋尝试访问资源,失败则加入到CLH队列。具体的,不同锁/同步器的实现需要一套线程阻塞以及被唤醒时锁分配的机制,这就是要重写、自定义的部分(其余等待队列的维护、调度、线程自旋阻塞的时机都已由JDK实现)
6)访问状态方式:
①getState();
②setState();
③compareAndSetState(a,b):当状态为a时才能设为b,即进行先验条件的检查
7)自定义锁/同步器时主要需要重写的几种方法
①isHeldExclusively();该线程是否正在独占资源,只有用到condition时才需要实现它
②boolean tryAcquire(int);以独占方式尝试获取资源
③boolean tryRelease(int);以独占方式尝试释放资源
④boolean tryAcquireShared(int);以共享方式尝试获取资源
⑤boolean tryReleaseShared(int);以共享方式尝试释放资源
12.活跃性问题
12.1死锁
Java应用程序不能从死锁中恢复,死锁是潜在难以发现的,一旦死锁发生则意味着部分系统的停止,想要恢复则只能重启应用程序并期盼死锁不会发生,因而需要确保应用程序避免发生死锁(数据库系统针对死锁做出了设计,两个事务发生死锁,当系统检查到死锁时会牺牲一个事务然后重新提交)
1)死锁的类型
①锁顺序死锁:线程1占用锁A,欲获得锁B,线程2占用锁B,欲获得锁A
静态锁顺序死锁;动态锁顺序死锁:程序获得锁的顺序不是静态定义的,而是通过传入参数的顺序动态决定的。
可以通过对象的哈希码System.identifyHashCode
来规定获得锁的顺序;很少会出现对象的哈希码相同的情况,如果出现可以增设加时赛锁,即类中再定义一个对象,增加它的synchronized锁,使得先获取它的线程可以执行。如果锁对象有可比较的key,则可通过key排序
②协作对象间的死锁:获得多个锁并非在同一方法中。在持有锁的时候调用外部方法存在活跃度的挑战,外部方法可能会尝试获取其他锁,带来死锁的风险;或遭遇严重的阻塞,导致其他线程难以获得该锁。
③资源死锁:由对锁的争用扩展到对资源的争用(如DB连接、线程饥饿死锁)也可将锁看着是资源的一种。
2)避免死锁的方法
①避免在一个原子操作/事务中同时获取多个锁/资源
②规定锁的顺序
③开放调用:调用的方法不需要锁时,即不通过锁来访问状态。通过减少锁的使用,来避免一个方法中同时需要多个锁
④定时锁:通过显示锁的trylock方法设置超时时间,超时则放弃锁
3)诊断死锁
通过线程转储分析死锁,通过向JVM发送命令对线程进行转储,生成日志进行分析
12.2其他活跃性问题
1) 饥饿
饥饿:当线程需要访问的资源永远被拒绝
发生饥饿的原因:线程的优先级不当(尽量不要通过Java平台修改线程的优先级)、在锁代码块中执行了无限循环或资源的无限期等待
2)弱响应性
在锁代码块中进行了耗时操作;在单一核心线程中进行耗时操作
解决:缩小锁的范围,将耗时操作在非主线程中执行
3)活锁
由于重试策略导致的活锁,一次活动由于某些原因未能成功执行,由重试策略,重新进行或插入到等待队列尾等待重新执行,结果可能由于错误每次都执行失败,而导致活锁。
解决:修正错误;在重试的等待时间中加入随机等待时长
13.性能和可伸缩性
13.1性能
1)一些改进并发程序性能的思想
①很多改进性能的技术同时增加了复杂度,因而增加了安全性和活跃性的风险
②相对于性能,安全性是第一位的,先正确再更快
③一个未经良好并发设计的程序可能比相同功能的顺序程序的性能更差
④避免不成熟的优化。首先使程序正确,然后再加快(如果它还不够快)
⑤对性能的追求很可能是并发bug唯一最大的来源
2)多线程引入的性能开销
①协调线程的开销(加锁、信号量、内存同步)
②线程阻塞时上下文切换的开销(线程阻塞包括多个方面,等待资源和线程,独占锁)
③线程创建和析构的开销
④线程调度的开销
3)监测CPU利用率
①若想充分利用机器算力,则想让处理器足够忙,即利用率足够高。低的利用率则意味着应用程序不足以充分利用计算资源
②测评系统性能,而不要臆测。windows的资源监视器perfmon能观测系统性能,使用市场上成熟的性能剖析工具能获得更详细的测评
③低CPU利用率可能的原因
- 不足的负载
- IO限制
- 外部限制:如网络、数据库连接
- 锁竞争
13.2可伸缩性
1)可伸缩性:指当增加计算资源的时候(CPU核数.内存.存储器.IO带宽)吞吐量(单位时间内的数据交换量)和生产量能够相应地改进
2)Amdahl定律
F:程序中串行程序所占的比重
N:CPU核数
SpeedUp:程序可获得的额外的计算资源数
S
p
e
e
d
U
p
≤
1
F
+
1
−
F
N
SpeedUp ≤ \frac {1} {F + \frac {1-F}{N}}
SpeedUp≤F+N1−F1
结论:程序的可伸缩性是由串行程序的比例决定的,可串行程序越少,程序的可伸缩性越多
3)什么会损害系统的可伸缩性
①过度串行化
②竞争的锁(首要威胁)
13.3减少锁的竞争
1)锁的竞争是指:独占锁对代码块的封锁使得对代码块的访问是同步的,从性能而言,其余线程需要阻塞等待,影响了性能;从可伸缩性而言,独占锁为了线程安全使得封锁代码块的同步化,从而影响了可伸缩性。
2)锁竞争的两个因素
①锁被请求的频率
②每次持有锁的时间
这两者的乘积决定着锁竞争性的强弱,乘积越大对性能和可伸缩性的损害也越大,反之越小。若乘积不大的话,这样的锁不会带来太大的性能和可伸缩性损害,因而无需考虑
3)如何减少锁的竞争
①减少锁的持有时间——缩小锁的范围(快进快出)
②减少访问锁的频率
- 缩小锁的粒度
通过分拆锁和分离锁实现,即对于一组状态变量,用多个锁而非一把锁进行封锁,由此减少锁的访问频率(这一组状态变量常为集合的元素,通过模运算分段加锁)
- 避免热点域
即对于一些操作,为了减少每次运算的时间,设置缓存变量保存计算结果,通过局部性的修改缓存变量来加快运算速度。但是由于线程安全问题,该变量需被加锁,因此它的访问频率很高,这样的变量称为热点域。
如计算集合长度的运算,每次计算可以遍历集合算出结果,时间复杂度为O(n),为了加快访问速度,则可以在集合中保持长度变量,每次add、remove时修改长度变量,计算长度时直接返回长度变量值即可,时间复杂度降为O(1)。但是该长度变量每次add、remove时都需要被访问,在多线程下为保证线程安全,需要加锁,该保护锁被访问的频率很高。这时为减少锁的访问频率,还是可以借鉴分离锁的思想,分段维持局部的长度变量,并用分离锁保护
③用协调机制代替独占锁,提高并发度
使用如并发容器、读-写锁、不可变对象、原子变量等协调机制代替独占锁