读书记录的一些知识点和部分其他资料的参考和理解,细节内容请参考其他资料
线程安全性
什么是线程安全性
当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。
原子性
竞态条件
1、当某个计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。换句话说就是正确的结果取决于运气。
2、最常见的竞态条件类型就是“先检查后执行(Check-Then-Act)操作,即通过一个可能失效的观测结果来决定下一步的动作。
示例:延迟初始化中的竞争态条件
1、使用“先检查后执行” 的一种常见情况就是延迟初始化。
2、延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行,同时要确保只被初始化一次。
3、竞态条件很容易与“数据竞争”相混淆。数据竞争是指,如果在访问非享的非final类型的域时没有采用同步来进行协同,那么就会出现数据竞争。并非所有的竞态条件都是数据竞争,同样并非所有的数据竞争都是竞态条件。
复合操作
加锁机制
要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。
内置锁 (Synchronized的使用)
1、java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)
2、同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字synchronuzed来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法以Class对象作为锁。
3、每个Java对象都可以用作一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lcok)。
4、sycnhronized的三种应用方式:
Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:
- 普通同步方法(实例方法),锁是当前实例对象 ,进入同步代码前要获得当前实例的锁
- 静态同步方法,锁是当前类的class对象 ,进入同步代码前要获得当前类对象的锁
- 同步方法块,锁是括号里面的对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
重入
1、由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。
2、重入意味着获取锁的操作的粒度是“线程”而不是“调用”。
3、重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。
4、这与pthread(POSIX线程)互斥体的默认加锁行为不通,pthread互斥体的获取操作是以“调用”为力度的。
用锁来保护状态
1、由于锁能使其保护的代码路径以串型形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。
2、一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在改对象上不会发生并发访问。
活跃性与性能
对象的共享
可见性
/**
* @description: 可见性
* @author: dsy
* @date: 2020/3/23 13:05
*/
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread{
@Override
public void run(){
while (ready){
Thread.yield();
}
System.out.println(number);
}
}
public static void main(String[] args){
new ReaderThread().start();
number = 4;
ready = true;
}
}
NoVisibility可能会持续循环下去,因为读线程可能永远看不到ready的值;也可能会输出0,因为度线程可能看到了写入ready的值,但却没看到之后写入number的值,这种线程被称为“重排序”。
失效数据
非原子的64位操作
1、当线程在没有同步的情况下读取变量时,可能会得到一个失效的值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性。
2、最低安全性适用于绝大多数变量,但是存在一个例外:非volatile类型的64位数值变量(double和long)。Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。
加锁与可见性
Volatile变量(可见性原理利用了MESI–缓存一致性协议,禁止重排序利用了内存屏障)
1、当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。
2、volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方。
3、调试小提示:对于服务器应用程序,无论在开发阶段还是在测试阶段,当启动JVM时一定都要指定-server命令行选项。server模式的JVM将比client模式的JVM进行更多的优化。
4、当且仅当满足以下所有条件时,才应该使用volatile变量:
- 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
- 该变量不会与其他状态变量一起纳入不变性条件中。
- 在访问变量时不需要加锁。
发布与逸出
1、“发布(Publish)”一个对象的意思是指,使对象能够再当前作用域之外的代码中使用。
2、当某个不应该发布的对象被发布时,这种情况就被称为逸出(Escape)。
3、安全的对象构造过程:不要在构造过程中使this引用逸出。
线程封闭
1、如果仅在单线程内访问数据,就不需要同步,这种技术被称为线程封闭。
2、线程封闭技术的一种常见应用是JDBC(Java Database Connectivity)的Connection对象。
3、线程封闭是在程序设计种的一个考虑因素,必须再程序中实现,Java语言及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和ThreadLocak类。但即便如此。但即便如此,程序员仍然需要负责确保封闭在线程中的对象不会从线程中逸出。
Ad-hoc线程封闭
1、Ad-hoc线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。
2、由于Ad-hoc线程封闭技术的脆弱性,在程序种尽量少用,可以使用更强的线程封闭技术,如栈封闭或者ThreadLocal类。
栈封闭
1、栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。
2、局部变量, 如果是基本类型或是包装类型,依然不能通过多线程改变其值,如果是对象,则其属性值是线程不安全的(对象引用是局部变量,在栈内存,但是对象本身还是处于堆内存)。
3、基本类型在成员变量和局部(local)变量的时候其内存分配机制是不一样的。
如果是成员变量,那么不分基本类型和引用类型都是在java的堆内存里面分配空间,而局部变量的基本类型是在栈上分配的。栈属于线程私有的空间,局部变量的生命周期和作用域一般都很短,为了提高gc效率,所以没必要放在堆里面。
Threadlocal类
1、这个类能使线程中的某个值与保存值的对象关联起来。
2、ThreadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。
3、ThreadLocal对象通常用于防止对可变的单实例变量或全局变量进行共享。
4、ThreadLocal是线程Thread中属性threadLocals的管理者。
不变性
1、即使对象中所有的域都是final类型的,这个对象也仍然是可变的,因为在final类型的域中可以保存对可变对象的引用。
2、不可变对象满足的条件:
- 对象创建以后其状态就不能修改。
- 对象所有的域都是final类型。
- 对象是正确创建的(创建期间this引用没有逸出)。
final域
1、final类型的域是不能修改的,但是如果final域所引用的对象是可变的,那么这些被引用的对象是可以修改的。
2、final域能确保初始化过程的安全,从而可以不受限制地访问不可变对象,并在共享这些对象时无须同步。
安全发布
不正确的发布:正确的对象被破坏
安全发布的常用模式
1、要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。
2、一个正确构造的对象可以通过以下方式来安全地发布:
- 在静态初始化函数中初始化一个对象引用。
- 将对象的引用保存到volatile类型的域或者AtomicReferance对象中。
- 将对象的引用保存到某个正确构造对象的final类型域中。
- 将对象的引用保存到一个由锁保护的域中。
对象的组合
设计线程安全的类
1、在设计线程安全类的过程中,需要包含以下三个基本要素:
- 找出构成对象状态的所有变量
- 找出约束状态变量的不变性条件
- 建立对象状态的并发访问管理策略
任务执行
在线程中执行任务
1、如果可运行的线程数量多于可用处理器的数量,那么有些线程将闲置。
2、大量空闲的线程会占用许多内存,给垃圾回收器带来压力,而且大量线程在竞争CPU资源时还将产生其他的性能开销。
取消与关闭
任务取消
1、如果外部代码能在某个操作正常完成之前将其置入“完成”状态,那么这个操作就可以称为可取消的。
2、Thread中断方法:
- inturrept():将中断状态置为true。
- isInterrupted():返回当前的中断状态
- isterrupted():清除当前状态,并返回它之前的值。
3、通常情况下,如果一个阻塞方法,如:Object.wait()、Thread.sleep()和Thread.join() 时,都会去检查中断状态的值,发现中断状态变化时都会提前返回并响应中断:清除中断状态,并抛出InterruptedException异常 。
4、该注意的是,中断操作并不会真正的中断一个正在运行的线程,而只是发出中断请求,然后由程序在合适的时刻中断自己。一般设计方法时,都需要捕获到中断异常后对中断请求进行某些操作,不能完全忽视或是屏蔽中断请求。
线程池的使用
一些概念和建议
1、如果所有正在执行任务的线程都由于等待其他处于工作队列中的任务而阻塞,那么会发生死锁。这种现象被称为线程饥饿死锁。
2、如果需要执行不同类别的任务,并且他们之间的行为相差很大,那么应该考虑使用多个线程池。
3、对于计算密集型任务,再N个处理器的系统上,当线程池的大小为N+1时,通常能实现最优的利用率。即使当计算密集型的线程偶尔由于页缺失或者其他原因而暂停时,这个额外的线程也能确保CPU的时钟周期不会被浪费,
4、对于包含I/O操作或者其他阻塞操作的任务,由于线程并不会一直执行,因此线程池的规模应该更大。要正确地设置线程池的大小,你必须估算出任务的等待时间与计算时间的比值。可以设置为2N + 1
5、线程池大小计算:N * U * (1 + W/C)
- N:处理器个数
- U:期望的CPU利用率
- W/C:等待时间与计算时间的比率
配置ThreadPoolExecutor
管理队列任务
1、只有当任务相互独立时,为线程池或工作队列设置界限才是合理的。如果任务之间存在依赖性,那么有界的线程池可能导致饥饿死锁问题。
饱和策略
1、如果在应用程序中需要利用安全策略来控制对某些特殊代码库的访问权限,那么可以通过Executor中的privilegedthreadFactory工厂来定制自己的线程工厂。通过这种方式创建出来的线程,将与创建privilegedthreadFactory的线程拥有相同的访问权限、AccessControlContxt和contextClassLoader。如果不使用privilegedthreadFactory,线程池创建的线程将从再需要新线程时调用execute或submit的客户程序中继承访问权限,从而导致令人困惑的安全性异常。
扩展ThreadPoolExecutor
1、ThreadPoolExecutor是可扩展的,它提供了几个可以再子类中改写的方法:beforeExecute、afterE小娥cute和terminated。
递归算法的并行化
1、如果循环中的迭代操作都是独立的,并且不需要等待所有的迭代操作都完成再继续执行,那么就可以使用Executor将串行循环转化为并行循环。
避免活跃性危险
1、如果在持有锁的情况下调用某个外部方法,那么就需要警惕死锁。
死锁的避免与诊断
支持定时的锁
1、还有一项技术可以检测死锁和从死锁钟回复过来,即显式使用Lock类中的定时tryLock功能来代替内置锁机制。
2、当使用内置锁时,只要没有获得锁,就会永远等待下去,而显式锁则可以指定一个超时时限,在等待超过该时间后tryLock会返回一个失败信息。
线程转储信息来分析死锁
1、JVM通过线程转储来帮助识别死锁的发生。
2、线程转储包括:
- 各个运行中的线程的栈追踪信息,这类似于发生异常时的栈追踪信息。
- 加锁信息,如每个线程持有了哪些锁,在哪些栈帧中获得这些锁,以及被阻塞的线程正在等待获取哪一个锁。
3、在生成线程转储之前,JVM将在等待关系图中通过搜索循环来找出死锁。如果发现了一个死锁,则获取相应的死锁信息。
其他活跃性危险
饥饿
1、当线程由于无法访问它所需要的资源而不能继续执行时,就发生了饥饿。
2、引发饥饿的最常见资源就是CPU时钟周期。
3、你经常能发现某个程序会在一些奇怪的地方调用Thread.sleep或Thread.yield,这是因为改程序企图克服优先级调整问题或响应性问题,并试图让低优先级的线程执行更多的时间。
糟糕的响应性
活锁
1、活锁问题尽管不会阻塞线程,但是也不能继续执行,因为线程将不断重复执行相同的操作,而且总会失败
2、活锁通常发生在处理事务消息的应用程序中:如果不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头。这种消息有时候也被称为毒药消息。
3、要解决这种活锁问题,需要在重试机制中引入随机性。
性能与可伸缩性
对性能的思考
1、要想通过并发来获得更好的性能,需要努力做好两件事:
- 更有效地利用现有的处理资源。
- 在出现新的处理资源时使程序尽可能地利用这些新资源。
2、Amdahl定律:在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中可并行组件与串行组件所占的比重。
线程引入的开销
上下文切换
1、按照经验来看,在大多数通用的处理器中,上下文切换的开销相当于5000~10000个时钟周期,也就是几微秒。
2、如果内核占用率较高(超过10%),那么通常表示调用活动发生的很频繁,这很可能是由I/O或竞争锁导致的阻塞引起的。
内存同步
1、同步操作的性能开销包括多个方面。
2、在synchronized和volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏(内存屏障)。内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及停止执行管道。–运用了缓存一致性协议
3、在内存栅栏中,大多数操作都是不能被重排序的。
4、优化重点应该放在那些发生锁竞争的地方。
阻塞
1、JVM在实现阻塞行为时,可以采用自旋等待或者通过操作系统挂起被阻塞的线程。如果等待时间较短,则适合采用自旋等待方式,而如果等待时间长,则适合采用线程挂起方式。
减少锁的竞争
1、有三种方式可以降低锁的竞争程度:
- 减少锁的持有时间
- 降低锁的请求频率
- 使用带有协调机制的独占锁,这些机制允许更高的并发性。
缩小锁的范围(快进快出)
1、将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作,以及可能被阻塞操作。
减小锁的粒度
1、另一种减小锁的持有时间的方式是降低线程请求锁的频率,这可以通过锁分解和锁分段等技术来实现,在这些技术中将采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。
锁分段
1、将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。
2、ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。
3、锁分段的一个劣势在于:要获取多个锁来实现独占访问将更加困难并且开销更高。
避免热点域
一些替代独占锁的方法
1、第三种降低竞争锁的影响的技术就是放弃使用独占锁,从而有助于使用一种友好并发的方式来管理共享状态。例如,使用并发容器、读写锁、不可变对象以及原子变量。
向对象池说“不”
减少上下文切换的开销
1、在服务器应用程序中,发生阻塞的原因之一就是在处理请求时产生各种日志消息。
显式锁
Lock与ReentrantLock
1、ReentrantLock实现了Lock接口,并提供了与synchronized相同的互斥性和内存可见性。
2、在获取ReentrantLock时,有着与进入同步代码块相同的内存语义,在释放ReentrantLcok时,同样有着与退出同步代码块相同的内存语义。
3、此外,与synchronized一样,ReentrantLock还提供了可重入的加锁语义。
轮询锁与定时锁
1、可定时的和可轮询的锁获取模式是由tryLock方法实现的。与无条件的锁获取模式相比,它具有更完善的错误恢复机制。
读写锁
1、在读取锁和写入锁之间的交互可以采用多种实现方式。ReadnWriteLock中的一些可选实现包括:
- 释放优先:当一个写入操作释放写入锁时,并且队列中同时存在读线程和写线程,那么应该优先选择哪个?还是按照请求顺序?
- 读线程插队
- 重入性:读取锁和写入锁是否是可重入的。
- 降级
- 升级
2、ReentrantReadWriteLock为读和写锁都提供了可重入的语义。
3、在Java5.0中,读取锁的行为更类似于一个Semaphore而不是锁,它只维护活跃的读线程的数量,而不考虑他们的标识。在Java6中修改了这个行为:记录哪些线程已经获得了读者锁。
原子变量与非阻塞同步机制
Java内存模型(JMM)
Java内存模型简介
1、Java内存模型是通过各种操作来定义的,包括对变量的读/写操作,监视器的加锁和释放操作,以及线程的启动和合并操作。
2、JMM为程序中所有的操作定义了一个便序关系,称之为Happens-Before。要想保证执行操作B的线程看到操作A的结果(无论A和B是否在同一个线程中执行),那么在A和B之间必须满足H-B关系。
3、如果两个操作之间缺乏H-B关系,那么JVM可以怼它们任意地重排序。
4、Happens-Before规则包括:
- 程序顺序规则
- 监视器锁规则
- volatile变量规则
- 线程启动规则
- 线程结束规则
- 中断规则
- 终结器规则
- 传递性