导图社区 JAVA并发编程理论基础
从计算机硬件角度分析了并发的不可避免行,从JVM规范分析了JAVA解决并发的原则,从JAVA语言特性分析了解决方案已经存在的风险,从API角度分析了JAVA如何简化并发变成,从量化角度分析如何合理使用并发变成
编辑于2021-06-17 14:40:25JAVA并发编程理论基础
剖析问题,01:可见性、原子性和有序性,并发bug之源
原子性问题
根源:线程切换
线程切换的原因
目的1:公平的分配CPU资源
目的2:I/O操作让出CPU执行权
IO不占用CPU
案例:共享数据的操作
1.数据竞争
2.竞态条件
3.解决方案:锁、CAS
有序性问题
根源:编译器优化
编译器优化的原因
目的1:改变程序执行顺序,提升性能
案例:双重检索
1.类的初始化问题
2.解决方案:final,volatile禁止编译优化
可见性问题
根源:不同CPU缓存间不可见,线程共享内存
CPU缓存不可见的原因
目的1:独立运算能力,提升任务处理能力
案例:数据不一致
1.缓存与内存数据不一致
2.缓存数据刷新到内存的不确定性
3.解决方案:锁、volatile
概要
原因
内存、CPU、I/O速度差异
CPU、内存、I/O 设备核心矛盾:速度差异。 CPU: 频率相应的单位有:Hz(赫)、kHz(千赫)、MHz(兆赫)、GHz(吉赫)。1GHz=1000MHz,1MHz=1000kHz,1kHz=1000Hz。 计算脉冲信号周期的时间单位及相应的换算关系是:s(秒)、ms(毫秒)、μs(微秒)、ns(纳秒),其中:1s=1000ms,1 ms=1000μs,1μs=1000ns。 CPU按照ns计算,十亿分之一秒 存储硬盘 硬盘是ms的执行单位,比较好的机械硬盘是10ms I/O操作 I/O操作一般按照 s 来衡量
平衡内存、CPU、I/O速度差异
1.CPU 增加了 缓存 ,以均衡与内存的速度差异; 2.操作系统增加了 进程、线程,以分时复用 CPU ,进而均衡 CPU 与 I/O 设备的速度差异; 3. 编译程序优化 指令执行次序,使得缓存能够得到更加合理地利用。 程序享受着这些成果的同时,并发程序很多诡异问题的根源也在这里。
总结
1.缓存导致的可见性问题,线程切换带来的原子性问题,编译优化带来的有序性问题
2.缓存、线程、编译优化的目的和写并发程序的目的是相同的,都是提高程序性能
02:java内存模型
概念: JVM 如何提供按需禁用缓存和编译优化的方法
具体: 三个关键字,六项规则
1.volatile
作用:禁用缓存以及编译优化
禁用缓存:CPU缓存中操作的数据,会强制刷新到内存中
禁用编译器优化:volatile声明的变量,该变量前面的代码可能会重排序,后面的代码也可能重排序,但是volatile声明的代码不会重排序,它会按照程序中代码顺序执行
解决了缓存不可见
局限
1.频繁的操作影响性能
2.不与synchronized混用
2.synchronized
作用:禁用缓存、编译优化以及线程切换时对资源的占有
1.使用管程模型,每次只有一个线程可以访问共享资源
3.final
作用:禁用编译器优化
使用场景:类中成员变量安全发布
案例:双重检索发布的成员变量
Java 领域一个经典的案例就是利用双重检查创建单例对象 public class Singleton { //static Singleton instance; final Singleton instance; static Singleton getInstance(){ if (instance == null) { synchronized(Singleton.class) { if (instance == null) instance = new Singleton(); } } return instance; } }
final具体的作用:防止构造函数的错误重排导致线程看到没有完全初始化的对象
详细剖析
1.未编译优化的成员变量初始化过程
1.分配一块内存 M; 2.在内存 M 上初始化 Singleton 对象; 3.然后 M 的地址赋值给 instance 变量。
2.编译优化的成员变量初始化过程
1.分配一块内存 M; 2.将 M 的地址赋值给 instance 变量; 3.最后在内存 M 上初始化 Singleton 对象。
优化后导致的问题: 1.线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上; 2.如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的, 如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。
4.Happens-Before
1. 程序的顺序性规则
单线程
定义:重排序不影响程序执行结果
解读:单线程有序性,happens-before第一条规则限制编译器优化
3.不适用于多线程
单线程的有序性,源于限制编译器优化
2.volatile变量规则
单线程、多线程
定义:一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。
解释:原始的意义就是禁用 CPU 缓存,将缓存中的数据立即刷新到内存
案例:volatile的可见性和传递性
volatile 保证x的值在多线程下保证可见性,根据传递性得到 class VolatileExample { int x = 0; volatile boolean v = false; public void writer() { x = 42; v = true; } public void reader() { if (v == true) { // 这里x会是多少呢?
适用场景
volatile规则是对内存的写操作,对于读取它的线程而一定是可见的。
反面案例:并发读,并修改
volatile的使用场景 问题: 在多核CPU中,两个线程同时将一个变量读入缓存(volatile a=3)。当一个线程将a修改成4了,但是还没有来得急将其刷新至内存。这样对于另外一个线程来说这个a是否是不可见?用volatile修饰了变量也无法保证100%可见? 分析: volatile规则:多线程中写对于读可以保证一定可见,其它的不保证。但是,两个线程同时读取volatile修饰的变量,其中一个线程将指修改,另一个线程对于他它新写入的值是不可见的。
解决了多线程的可见性问题
3.特性:传递性
单线程、多线程
定义:A Happens-Before B,B Happens-Before C,则A Happens-Before C。
解释:保证volatile之前的数据会立即刷新到缓存
java并发包的实现原理之一
传递性增强volatile的语义
6. 线程 join() 规则
定义:针对线程等待。主线程 A 通过调用子线程 B 的 join() ,当子线程 B 完成后,主线程能够看到子线程的操作。既“看到”,对共享变量的操作。
案例
Thread B = new Thread(()->{ // 此处对共享变量var修改 var = 66; }); // 例如此处对共享变量修改, // 则这个修改结果对线程B可见 // 主线程启动子线程 B.start(); B.join() // 子线程所有对共享变量的修改 // 在主线程调用B.join()之后皆可见 // 此例中,var==66
注意
静态方法中使用上面的方式,无法使用共享变量。静态方法中使用创建线程本身就是一件错误的事情
5. 线程 start() 规则
定义:针对线程启动。主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作
解释:A 调用线程 B 的 start() 方法前,则A对共享变量的所有操作,在调用start()后,对于B线程均可见
案例
Thread B = new Thread(()->{ // 主线程调用B.start()之前 // 所有对共享变量的修改,此处皆可见 // 此例中,var==77 }); // 此处对共享变量var修改 var = 77; // 主线程启动子线程 B.start();
4. 管程中锁的规则
多线程、单线程
定义:同一把锁的解锁 Happens-Before 于后续对这个锁的加锁。
管程:管理共享变量以及共享变量的操作过程
java的实现方式:封装、同步
封装:synchronized封装类的成员方法和成员变量
同步:wait、notify、notifyAll,实现线程间的协作
实现原子性、可见性、有序性。活跃性、性能问题
性能问题解决方案:JVM的逃逸分析、锁合并、读写锁、写入时复制...
活跃性问题:死锁、饥饿、活锁
死锁解决方案:
1.破坏占用且等待
方案:一次申请所需资源
2.破坏循环等待
方案:根据锁的线性条件排序
3.破坏不可抢占
方案:java的SDK的Lock提供释放资源的操作
饥饿解决方案
1.保证资源充足
局限:资源的稀缺性是没办法解决
2.公平地分配资源
局限:使用范围较广,但是他对性能有一定影响
3.避免持有锁的线程长时间执行
局限:持有锁的线程执行的时间也很难缩短
活锁解决方案
bug源头:互相谦让导致
方案:获取资源加上随机时间
案例: 的分布式一致性算法Raft中用到了加随机时间的方案
案例:对共享变量操作的封装,在方法上加synchronized
happens-before本质是保证可见性
操作系统
内存屏障
解决的问题
缓存导致的可见性问题
缓存中的数据与主内存的数据并不是实时同步的, 各CPU间缓存的数据也不是实时同步. 在同一时间点, 各CPU所看到的同一内存地址的数据的值可能是不一致的.
编译器优化导致的重排序问题
多核多线程中, 指令逻辑无法分辨因果关联, 可能出现乱序执行, 导致程序运行结果错误
具体方案
写内存屏障(Store Memory Barrier)
写内存屏障(Store Memory Barrier): 在指令后插入Store Barrier, 能让写入缓存中的最新数据更新写入主内存, 让其他线程可见 强制写入主内存, 这种显示调用, CPU就不会因为性能考虑而进行指令重排
读内存屏障(Load Memory Barrier)
读内存屏障(Load Memory Barrier): 在指令前插入Load Barrier, 可以让高速缓存中的数据失效, 强制重新从主内存读取数据 强制读取主内存内容, 让CPU缓存和主内存保持一致, 避免了缓存导致的一致性问题
案例分析:没有volatile,依然可以保证可见性
示例 public static void main(String[] args) { A a = new A(); //A线程 new Thread(()->a.m()).start(); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } a.running = false; } 一、running没有用volatile修饰,该变量无法保证可见性问题。之所以每次其他的线程可以看到操作的结果在于,sleep 2秒,相对于cpu已经是很长的时候,cpu缓存可能已经将缓存刷新到内存。 二、CPU的是按照nm工作,内存操作按照微秒工作,内存的工作时间是CPU的千分之一。 三、s(秒)、ms(毫秒)、μs(微秒)、ns(纳秒),其中:1s=1000ms,1 ms=1000μs,1μs=1000ns cpu时间单位是nm(纳秒)
操作系统实现可见性、有序性原理
锁在JVM中的操作
1.在虚拟机的对象头,2个比特存放锁标志位
01未锁定
00轻量级锁
目的:无竞争条件下使用CAS操作消除使用同步原语
状态变化条件:至少有一个线程抢占锁,轻量级锁会膨胀为重量级锁
理论基础:绝大部分锁,整个同步周期内都是不存在竞争。
优势:轻量级锁不会使用操作系统提供的互斥量,进而开销小
缺点:多线程竞争,互斥量、CAS开销,会比重量级锁更慢
10重量级锁
1.使用操作系统的互斥量实现原子性
01偏向锁
目的:在无竞争情况下把整个同步以及CAS清除
开启JVM偏向锁:-XX:UseBiasedLocking
JVM实现加锁解锁的方式
03和04:互斥锁
03 解决原子性问题
1.解决多线程下的原子性问题
原子性问题:由线程切换导致
线程切换发生的时机
宏观:操作系统的线程切换依赖CPU中断
微观
1.线程的CPU执行时间到期
2.高级语言由多条CPU指令组成,可能发生在任何位置,如i++
3.CPU指令的原子性和高级语言的原子性不是同一个概念
CPU保证的是指令级别的原子性
高级语言保证对临界区操作的原子性,即发生线程切换,其他线程无法操作共享变量
原子问题发生的原因
2.思想:同一时刻只有一个线程执行临界区,成为互斥
临界区:多个线程操作共享资源的代码片段,但每次只允许一个线程操作
3.锁在JAVA中的实现
synchronized
作用域
方法
静态
锁类型:类锁,全局锁
非静态
锁类型:对象,this
代码块
锁类型:对象,this
synchronized如何保证原子性
解决线程切换,其他线程操作共享变量
1.从线程的角度:在没有锁的情况下发生线程切换,意味这有其他的线程可以操作共享变量,造成共享变量无法保证原子性。 2.在加锁的情况下,即使线程切换,其他的线程也无法操作锁保护的临界期,保证了原子性
劣势
1.不支持锁中断
2.不支持超时释放锁
3.在等待-通知机制中只支持单个条件变量
4.非公平锁
优势
1.java内置锁,会随着JVM升级得到性能提升
2.隐式锁,自动加锁和解锁
3.性能好,与Lock在同一数量级
synchronized的中间态可见问题
案例1:写方法加锁
synchronized void test() { //操作1 value = value + 1; //业务方法.... //操作2 value = value + 2; }
问题: 操作1 会立即刷新到缓存?
问题分析
1.原子性问题,中间态是否可见问题
2.锁保护的操作,中间态刷新到缓存
synchronized 能保证互斥,所以操作1完成后刷入内存也没问题。如果同步代码块里要操作10亿个共享变量,它只能放到内存中。如果放到CPU缓存中,那缓存肯定要爆表。
知识关联
2.锁的原子性只针受同一把锁保护的操作
一、高级语言层面原子性指操作一组对共享变量的操作对于其他线程而言只能看到两个状态,即初始状态和最终状态,中间状态不可变。这种原子性操作需要锁来实现。 二、在synchronized修饰的方法中,仅能保证访问同一把锁时保证只能看到共享变量的初态和终态。 三、如果写操作加锁,而读操作不加锁。那么读操作要么读取到未更新数据,要么读取到加锁操作的中间状态。 四、锁的原子性只针受同一把锁保护的操作 五、 1)写操作加锁保证对共享变量操作的原子性。 2)对于读操作,如果读取操作没有涉及到状态依赖,它本身可以保证原子性,但是无法保证他的可见性。 原因 : synchronized修饰的方法,方法执行过程中产生的中间状态值是会(可能立即)刷新到缓存,没有加锁的读取方法读取的可能就是中间状态的脏数据。
案例2:synchronized和volatile混用
volatile int value = 0; synchronized void test() { //操作1 value = value + 1; //业务方法.... //操作2 value = value + 2; } public int get(){ return value; }
1.基于各自特点,混用造成并发问题
问题:volatile和synchronized一起使用,由于他们本身的特点,可能造成并发问题。 分析: 1.volatile的本质通过写内存屏障实现CPU缓存和内存中数据的一致性。 2.synchronized使用互斥,保证临界区只有一个线程访问。根据Happens-before规则,线程释放锁,会将操作数据刷新到内存。线程获取锁,会使获取锁的线程缓存数据失效,重新从内存中加载数据。 3.两种机制的使用将无法保证原子性。写入操作使用synchronized机制,在读取中使用volatile保证可见性机制。使得synchronized中对共享变量的操作立即刷新到缓存,线程访问get()会获取共享变量的中间状态 4.无论是否使用volatile保证get()的可见性,synchronized都会讲操作的中间状态刷新到缓存
子主题
Lock
作用域
方法
代码块
优势
1.支持公平锁、非公平锁
2.支持锁中断
3.支持超时锁
4.等待-通知机制支持多个条件变量
劣势
1.手动加锁、释放锁。如果忘记解锁,会造成死锁
2.非java内置锁,无法随着JVM升级而得到性能提升
遵循happens-before管程中锁规则,同一把锁的解锁happens-before后续对该锁的加锁操作
锁的选择策略分析
1.静态锁、类锁
全局唯一锁
1.静态锁:静态锁是全局锁,class文件在JVM中具有唯一性。使用全局锁可能造成在该锁上的竞争激烈,导致程序的并发度降低。但是工程上,这样容易出问题。因为这个锁是JVM全局唯一的。有可能别人也用这个。比如说有人使用class做反射
2.使用一个静态锁保护一个非静态资源不合适,导致类的不同实例可能竞争同一个锁
3.不使用String、Integer等不可变对象的字面量作为锁
3.不使用String字面量作为锁,可能会被其它他对象引用。导致死锁。
原因从锁的本质理解
锁标志
一、JVM虚拟机的对象头中存放对象的运行时数据,2个比特存放锁标志。 二、 01未锁定 00轻量级锁 10重量级锁 01偏向锁
4.锁对象不可变
不要用non-final的field来作为锁,non final的对象可能会随时被改变,而导致两个线程synchronize on different object。
锁和临界区
关系:受保护资源和锁之间的关联关系是 N:1 的关系
正确使用锁
案例1,共享变量写操作加锁,读操作未加锁
class SafeCalc { long value = 0L; long get() { return value; } synchronized void addOne() { value += 1; } } 1.addOne 单核 CPU以及多核 CPU,只有一个线程能够执行 addOne() 方法,所以一定能保证 原子操作 , Happens-before中的管程中锁的规则解释。对同一把锁的解锁Happens-Before于后续对这个锁的枷锁,保证 可见性 Happens-before本质上是保证可见性 2.get 1)该方法本身就是原子,它的主要问题是可见性问题。 2)未加锁,则可能读取到过期数据,也可能是共享数据的中间状态。 3)使用同一把锁的临界区,可以保证临界区之间只能看到对共享变量的初态和终态,不会看到中间态。 3. 管程中的锁规则,只针对后续这个锁的加锁的可见性,而get没有加锁,所以没有办法保证他的可见性。 4. get()加锁,则get和addOne是互斥的,可以保证读写的一致性
资源路径和锁的关系
原因:临界区的代码是操作受保护资源的路径,但不是随便一把锁都能有效。所以必须分析锁定的对象和受保护资源的关系,考虑受保护资源的访问路径的关系才能用好互斥锁。
4.思考题
class SafeCalc { long value = 0L; long get() { synchronized ( new Object() ) { return value; } } void addOne() { synchronized ( new Object() ) { value += 1; } } } 代码存在并发问题,不发保证互斥
1.知识扩展
1.临界区
访问共用资源的程序片段,这些共用资源又无法同时被多个线程访问的特性。
临界区指的是一个访问共用资源(例如:共用设备或是共用存储器)的程序片段,而这些共用资源又无法同时被多个线程访问的特性。当有线程进入临界区段时,其他线程或是进程必须等待,有一些同步的机制必须在临界区段的进入点与离开点实现,以确保这些共用资源是被互斥获得使用,例如:semaphore。只能被单一线程访问的设备,例如:打印机。
2.逃逸分析
3.Thread.sleep(0)
重新分配CPU执行权
sleep方法背后涉及到了操作系统对线程的调度。 线程是一个操作系统执行任务的基本单位,处理器的数量决定了不可能所有线程都能同时得到执行。这就需要通过某种算法来进行任务调度。Unix系统使用的是时间片算法,而Windows 是一个抢占式的多任务操作系统。 操作系统的优先级问题 : 1.在某些操作系统下,具有最高优先级(相对于可执行线程而言)的线程经过调度后总是首先运行。 2.如果具有相同优先级的多个线程都可用,则计划程序将遍历处于该优先级的线程,并为每个线程提供一个固定的时间片(段)来执行。 只要具有较高优先级的线程可以运行,具有较低优先级的线程就不会执行。 3.如果此时具有较高优先级的线程可以运行,则具有较低优先级的线程将被抢先,并允许具有较高优先级的线程再次执行。 执行 Thread.sleep(0) 代码的作用: 1.当前线程不一定会被唤醒,虽然休眠了0秒,但是执行 sleep 方法后,不仅会休眠,还会让 CPU 重新分配。 2.Thread.Sleep(0)的作用,就是“触发操作系统立刻重新进行一次CPU竞争”。竞争的结果也许是当前线程仍然获得CPU控制权,也许会换成别的线程获得CPU控制权。 3.大循环里面经常会写一句Thread.Sleep(0) ,因为这样就给了其他线程获得CPU控制权的权力,这样一些功能就不会假死在那里。
4.类锁和对象锁的关系:平行关系
2.思考题分析
宏观
1.思考题分析:两把不同的锁,不能保护临界资源。new Object使得每一个线程都会有一把自己的锁,JVM会将这种非竟态锁去掉。所以这种情况相当于无锁。
微观
1.get不加锁会获取什么数据
问题 get方法如果不加锁,另一个线程读取的时候,不一定是从主内存读,sync同步方法释放锁后也不会使得其他线程的缓存失效? 结果: 1.synchronized会使得进入改同步块的缓存失效。 2.synchronized使得缓存失效的前提,他们使用同一把锁。 3.如果不加锁,另一个线程读取的数据是从主内存中读取的,但是读取到的可能是synchrinized操作的中间状态 原因: 1.在使用同一把锁的情况下,当前线程在同步块前和同步块中,对内存的写操作对于其他访问相同同步块(使用同一个monitor)的线程是可见的。 2.在退出同步块前,将缓存数据刷新到内存,这样当前线程的写作对于其他线程是可见的。 3.当进入同步快之前,会获取monitor,使得当前处理器的缓存失效,从而读取数据必须从内存中重新加载,这样就可以看到其他线程在同步块中写操作。
get()加锁目的:多线程中add()操作对于get()可见
get不加锁没有办法保证可见性原因: 1.因为缓存原因,在不使用锁或者volatile时,它读取的数据可能来自缓存,从而无法看到最新的写入值。 2.从另一个方面来看 ,get操作没有状态依赖,这个读取操作本身就是原子性,加锁是为了实现不同线程间的add方法的操作结果对get方法可见性。 3.并发包里的原子类实现的原理是类似的。
2.并发包里的原子类实现的原理是类似的。在读写操作加锁实现多线程下的数据一致性。同步容器的原理就是基于此。
04 一把锁保护多个资源
没有关联关系的资源
细粒度锁
如果资源没有关联关系,使用细粒度锁可以提高并行执行效率。 用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁叫细粒度锁。
提高并行执行效率
案例:不同资源使用不同锁保护
class Account { // 锁:保护账户余额 private final Object balLock = new Object(); // 账户余额 private Integer balance; // 锁:保护账户密码 private final Object pwLock = new Object(); // 账户密码 private String password; // 取款 void withdraw(Integer amt) { synchronized(balLock) { if (this.balance > amt){ this.balance -= amt; } } } // 查看余额 Integer getBalance() { synchronized(balLock) { return balance; } } // 更改密码 void updatePassword(String pw){ synchronized(pwLock) { this.password = pw; } } // 查看密码 String getPassword() { synchronized(pwLock) { return password; } } }
锁对象用final修饰,保证不可变性。锁对象封装在类内部,提高安全性
有关联关系的资源
1.基于关联关系,使用的锁要覆盖所有的资源
2.正确的使用锁保护多个资源
使用一把锁保护多个资源:现实世界中的包场
方案1:唯一外部对象
class Account { private Object lock; private int balance; private Account(); //防止误用默认构造方法,使得没有传入锁对象 // 创建Account时传入同一个lock对象 public Account(Object lock) { this.lock = lock; } // 转账 void transfer(Account target, int amt){ // 此处检查所有对象共享的锁 synchronized(lock) { if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } } 1.创建 Account 对象的时候必须传入同一个对象,如果创建 Account 对象时,传入的 lock 不是同一个对象,出现并发问题( 安全性、活跃性 、性能问题) 2.在真实的项目场景中,创建 Account 对象的代码很可能分散在多个工程中,传入共享的 lock 真的很难。
实际项目不具有实用性
方案2:类锁
class Account { private int balance; // 转账 void transfer(Account target, int amt){ synchronized(Account.class) { if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } } Account.class 是所有 Account 对象共享的,该对象是 Java 虚拟机在加载 Account 类的时候创建的,具有全局唯一性。使用 Account.class 作为共享的锁,可以保证Account任意示例都在锁的保护范围内。
类锁范围太大,串行化比例过大,有性能问题
3.锁的作用范围无法覆盖所有资源
案例
//账户类 class Account { private int balance; // 转账 synchronized void transfer( Account target, int amt){ if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } 银行业务里面的转账操作,涉及到账户A和账户B。 问题出在this这把锁,this 这把锁可以保护自己的余额 this.balance,却保护不了别人的余额 target.balance。 所以this锁无法实现资源互斥,无法保证线程安全。
4.关联关系是原子性的一种特征
“原子性”的本质:其实不是不可分割,不可分割只是外在表现。本质是多个资源间有一致性的要求,操作的中间状态对外不可见。
使用合适的锁,需要分析出有访问资源有哪些路径
思考题
class Account { // 锁:保护账户余额 private final Object balLock = new Object(); // 账户余额 private Integer balance; // 锁:保护账户密码 private final Object pwLock = new Object(); // 账户密码 private String password; // 取款 void withdraw(Integer amt) { synchronized(balLock) { ... } } // 查看余额 Integer getBalance() { synchronized(balLock) { ... } } // 更改密码 void updatePassword(String pw){ synchronized(pwLock) { this.password = pw; } } // 查看密码 String getPassword() { synchronized(pwLock) { return password; } } } 两把不同的锁来分别保护账户余额、账户密码,创建锁的时候,使用的是:private final Object xxxLock = new Object(); 如果账户余额用 this.balance 作为互斥锁,账户密码用 this.password 作为互斥锁,是否可以呢?
思考题分析
锁的选取有问题
1.balance,password是可变对象
Integer,String不可变对象,但对外提供修改方法。每次赋值都会生产新的对象,无法保证互斥
可变对象不能作为锁
知识扩展
并发概念
1.在互联网概念中,并行度是指可同时开辟的线程的数量
2.并发数是指每个线程中可同时处理的最大数据量
静态锁
可以作为全局锁,但是比类锁安全,类锁的力度太大,难以在实践中使用
class Account { private static Object lock = new Object(); private int balance; // 转账 void transfer(Account target, int amt){ synchronized(lock) { if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } } 这种方式比锁class更安全,因为这个缺是私有的。有些最佳实践要求必须这样做。 比如,一个线程使用类锁,如果有其他的线程使用反射,就会出问题。class在java中应用广泛,使用类锁会造成一个莫名其妙的bug,难以追踪排查。
Integer 缓存-128到127的数值
一、Integer是不可变量,每次赋值都会产生新的对象
二、Integer的字面值缓存:-128-127
Integer a = 12; Integer b = 12; System.out.println(a.equals(b)); 结果为true
三、new 的方式创建两个Integer对象,他们不会缓存的数字,因此是不相同的两个变量
四、JVM设置Integer缓存范围
Integer 的内部的私有静态类IntegerCache,首次使用会初始化缓存大小,缓存大小可以在启动JVM设置缓存大小,默认的字面值缓存范围-128到127.可以通过-XX:AutoBoxCacheMax=<size> 设置自动装箱最大缓存范围。
Integer b = 12; Integer a = 12; Integer c = 140; Integer bc = 140; System.out.println(a==b); 默认情况下为true System.out.println(c == bc);默认为false。如果设置 -XX:AutoBoxCacheMax=size ,可以让结果为true
五、Integer不适合做锁
integer在-128~127之间 锁是无效,因为其他功能也可能用到这个锁
子主题
具有缓存特性的字面值变量不可以作为锁
例如:String、Integer的字面值在系统中会在其他的地方使用,造成活跃性问题
分布式锁,用zk,数据库,redis
一般利用数据库的原子性来 做转账操作,
可变对象的相对性
可变或不可变对象对外提供了修改方法,不可以作为锁
一、使用 private final Object balLock = new Object();的目的就是保证在一个对象里面balLock是不可变的,所以使用private final修饰代表私有不可变, 二、Integer也是fianl类型,但是对外提供了修改的方法,在一个对象里面,它是可变对象,可变对象无法作为锁。
对象初始化问题
对象初始化必须传入某个参数,为防止误用,将无参构造私有化
05 死锁问题
锁粒度大,任务串行化,延迟增加,吞吐量小
细粒度锁,任务并行化,延迟减小、吞吐量增加。 容易死锁
死锁:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
死锁案例
转账案例 这个柜员在拿账本的时候可能遇到以下三种情况: 1.转出账本和转入账本 -> 同时拿走; 2.只有转出账本和转入账本之一 -> 拿走其中一本 -> 等待另一本; 3.转出账本和转入账本都没有 -> 等待两个账本送回。 代码实现: 1)用两把锁实现,转出账本一把锁,转入账本另一把锁。 2)在 transfer() 方法内部,首先尝试锁定转出账户 this(先把转出账本拿到手),然后尝试锁定转入账户 target(再把转入账本拿到手),只有当两者都成功时,才执行转账操作。 class Account { private int balance; // 转账 void transfer(Account target, int amt){ // 锁定转出账户 synchronized(this) { // 锁定转入账户 synchronized(target) { if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } } } 一、Account.class 作为互斥锁,锁定的范围太大。 二、使用两个锁来锁定两个账户,使得锁的覆盖范围减小,这样的锁叫细粒度锁。 三、使用细粒度锁可以提高并行度,是性能优化的一个重要手段。 四、使用细粒度锁是有代价的,这个代价就是可能会导致死锁。 导致死锁的原因是锁的顺序问题。
利:细粒度锁使性能优化的重要手段
弊:细粒度锁容易造成死锁
死锁发生条件
1.互斥
1.互斥,共享资源 X 和 Y 只能被一个线程占用
互斥锁的基础,无法破坏该条件
2.占有且等待
占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
解决思路:破坏占有且等待
一次性申请所有的资源
1.对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
现实:账本管理员,统一管理资源
编程:资源管理类,统一申请、释放资源
方案一
1.全局唯一性,用单例模式
2.申请、释放资是源临界区,需要锁保证互斥
3.申请资源存放集合
一、已经被申请的资源存放在集合中,其它线程申请资源时,首先判断集合中是否存在。 二、如果集合存在,则该资源已经被申请,进行循环等待,思想上和自旋锁类似。
4.释放资源,从集合中移除
5.代码
资源管理类
/** * 该类作用的是一次申请资源 */ public class Allocator { private static List<Object> als = new ArrayList<>(); private static volatile Allocator allocator= null; private Allocator(){} //一次申请资源 synchronized boolean apply(Object from, Object to) { //这里使用if判断,只判断了一次,但是在使用该类申请资源的时候,使用的while死循环判断,不断检测资源的占用情况 if (als.contains(from) || als.contains(to)){ return false; }else { als.add(from); als.add(to); } return true; } //释放资源 synchronized void free(Object from ,Object to){ als.remove(from); als.remove(to); } //单利模式 双重检索 public static Allocator getInstance(){ if (allocator == null){ synchronized (Allocator.class){ if (allocator == null){ allocator = new Allocator(); } } } return allocator; } }
资源申请类
public class Account { private int balance; public Account(int balance) { this.balance = balance; } //获取Allocator的单例模式 public Allocator getInstance() { return Allocator.getInstance(); } //转账 使用多个锁,因为一次申请了所有的资源,所以不存在死锁问题 void transfer(Account target, int amt) { //转账,一次申请所有的资源 //使用自旋的方式判断是否获取了锁,使用while不断检测资源。和等待-通知机制的while方式是一样,在await()在唤醒的时候需要再次进行检测,判断条件是否满足 //原因是,在notify通知时间的状态和线程重新获取锁不在同一个时间点,满足线程的状态可能已经发生变化。所以需要在等待的线程获取到锁后,需要重新判断条件是否满足。 while (!getInstance().apply(this, target)) ; try { //锁定转出账户 synchronized (this) { //已经申请资源,再次加锁的原因:有其他的业务使用账户对象。例如,取款业务 //锁定转入账户 synchronized (target) { System.out.println(Thread.currentThread().getName()); if (this.balance > amt) { this.balance -= amt; target.balance += amt; System.out.println(Thread.currentThread().getName() + " from : " + this.balance + " , to : " + target.balance); } } } } finally { getInstance().free(this, target); } } }
资源调用类
使用闭锁模拟并发请求 public class Mian { public static void main(String[] args) { Account a = new Account(1000); Account b = new Account(900); Account c = new Account(900); CountDownLatch latch = new CountDownLatch(1); for (int i = 0 ;i < 500 ;i++){ new Thread(()->{ try { latch.await(); a.transfer(b,200); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); new Thread(()->{ try { latch.await(); b.transfer(a,200); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); System.out.println("66666"); } latch.countDown(); } }
自旋锁的方案
自旋锁和互斥锁区别
自旋锁是一种互斥锁的实现方式,相比一般的互斥锁会在等待期间放弃cpu,自旋锁(spinlock)则是不断循环并测试锁的状态,一直占用CPU执行权 互斥锁:用于保护临界区,确保同一时间只有一个线程访问数据。对共享资源的访问,先对互斥量进行加锁,如果互斥量已经上锁,调用线程会阻塞,直到互斥量被解锁。在完成了对共享资源的访问后,要对互斥量进行解锁。 临界区:每个线程中访问临界资源的程序片段称为临界区,每次只允许一个进程进入临界区,进入后不允许其他进程进入。 自旋锁适用场景: 使用场景:锁竞争不激烈,锁持有的时间短,线程并不希望在重新调度上花太多的成本。 自旋锁与互斥锁的区别:线程在申请自旋锁的时候,线程不会被挂起,而是处于忙等的状态。
大量占用CPU,在CPU是稀缺性资源,不该采用这种方案
方案二
基于AQS同步队列,减少CPU消耗
方案三
等待-通知机制
该方案需要确保一资源只有一个实例,但在项目中这点很难保证
实用性不好
3.不可抢占
3.不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
解决思路:破坏不可抢占
主动释放占有的资源
2.对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
Lock锁可以解决该问题
一、破坏不可抢占条件的核心是要能够主动释放它占有的资源。 二、 synchronized 无法实现该功能。 原因:synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了。线程进入阻塞状态,什么都做不了,也无法释放线 程已经占有的资源。 二、Java 在语言层次确实没有解决这个问题,在 SDK 层面,java.util.concurrent 这个包下面提供的 Lock 是可以解决这个问题的。
4.循环等待
4.循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
解决思路:破坏循环等待
按序申请资源
3.对于“循环等待”这个条件,可以靠按序申请资源来预防。按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
根据ID或其它线性字段排序
破坏循环等待,需要对资源进行排序,然后按序申请资源。假设每个账户都有不同的属性 id,这个 id 可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。 class Account { private int id; private int balance; // 转账 void transfer(Account target, int amt){ Account left = this ① Account right = target; ② if (this.id > target.id) { ③ left = target; ④ right = this; ⑤ } ⑥ // 锁定序号小的账户 synchronized(left){ // 锁定序号大的账户 synchronized(right){ if (this.balance > amt){ this.balance -= amt; target.balance += amt; } } } } }
按序申请解释
问题:循环等待 一、B转A的同时,A转账给B,线程T1先锁B再锁A,线程T2先锁A再锁B。 二、如果两个线程同时执行,就会出现死锁的情况,线程T1锁了A请求锁B,此时线程T2锁了B请求锁A,都在等着对方释放锁,然而自己都不会释放锁。 解决: 无论哪个线程执行的时候,都按照顺序加锁,即按照A和B的id大小来加锁。这样,无论哪个线程执行的时候,都会先加锁A,再加锁B,A被加锁,则等待释放。这样就不会被死锁了。
死锁发生条件,破坏一个条件即可
思考题
破坏占用且等待条件,使用细粒度是锁了所有的账户,还是用了死循环 while(!actr.apply(this, target));该方法与 synchronized(Account.class) 有没有性能优势呢?
思考题分析
锁定资源的方式不同
集合装载资源
资源申请串行,不同用户转账时并行
使用锁锁定资源
所有的操作都是并行,存在性能问题
概要
如果申请资源使用集合,集合的hash算法和赋值时间复杂度低。 如果使用一个全局锁来锁住使用的所有资源,锁中的业务代码执行时间很长。 所以综上两种申请资源的方式,使用集合来存放申请的资源,远比使用锁的性能更好。 使用一个全局锁来锁住资源,所有的转账操作都是串行,在现实中这种性能是无法接受的。使用一个集合存放申请的资源,这个申请是串行的,但是不同的转账操作之间是并行执行。性能提升明显。
关联知识
1.锁设定超时时间
锁设定超时时间,防止死锁导致严重的生产事故
SDK中的Lock锁提供了超时锁
2.涉及到钱,开发中都是用数据库事务+乐观锁的方式解决资源锁定问题
数据库有完善的死锁解决能力,事物回滚机制
防止死锁,按照id的大小顺序依次锁定
3.判断多线程阻塞原因
top命令查看Java线程的cpu利用率,用jstack来dump线程。开发环境可以用 java visualvm查看线程执行情况
4.不变对象的相对性
void transfer(Account target, int amt){ Account left = this ① Account right = target; ② if (this.id > target.id) { ③ left = target; ④ right = this; ⑤ } ⑥ // 锁定序号小的账户 synchronized(left){ // 锁定序号大的账户 synchronized(right){ if (this.balance > amt){ this.balance -= amt; target.balance += amt; } } 问题: 不可变对象不可以做锁,这里为何使用left作为锁? 分析: 对象的中的成员会发生变化,但是对象本身不会发生变化。因为锁的本质是锁定该对象的对象头中的锁标志,和对象中的成员变量没有关系。 不是绝对不能用于可变对象作为锁,使用不变对象作为锁只是一条最佳实践。 锁,需要明确我们需要保护的可变状态,相对于可变状态,什么是不变的。上面的例子可以看出,在进行转账时变的是账户余额,不变的是账户状态所在的A对象实例,所以通过对A对象的保护,达到保护账户状态的目的。
5.申请资源后,又分别锁定资源
其目的是为了防止有其他的业务对资源的操作。
6.数据库里有死锁检测,一般的程序不会做处理
死锁检测:银行家算法
小结
细粒度锁存在死锁问题,选择破坏死锁成本较小的方案
一、细粒度锁来锁定多个资源时,要注意死锁的问题。遇到细粒度锁的使用场景,要想到可能存在死锁问题,进而规避死锁。 二、预防死锁主要是破坏三个条件中的一个,有了思路后,实现就简单了。 三、有时候预防死锁成本也是很高的。例如上面转账那个例子,破坏占用且等待条件的成本就比破坏循环等待条件的成本高。破坏占用且等待条件,锁了所有的账户,而且还是用了死循环 while(!actr.apply(this, target));方法,在锁竞争不激烈的情况 apply() 这个方法基本不耗时。 在转账这个例子中,破坏循环等待条件就是成本最低的一个方案。 四、所以我们在选择具体方案的时候, 还需要评估一下操作成本,从中选择一个成本最低的方案。
06线程协作机制,等待-通知
破坏占用且等待条件 -> 等待-通知机制
案例:使用死循环检测资源
核心代码如下: // 一次性申请转出账户和转入账户,直到成功 while(!actr.apply(this, target))
死循环检测缺点
高并发场景,占用CPU过多
并发量较低场景: 如果 apply() 操作耗时非常短,而且并发冲突量也不大时,该方案可以使用,在这种场景下,循环上几次或者几十次就能一次性获取转出账户和转入账户了。 高并发场景: 如果 apply() 操作耗时长,或者并发冲突量大的时候,循环等待这种方案就不适用了,在这种场景下,可能要循环上万次才能获取到锁,太消耗 CPU 了。
等待-通知机制可以较好处理占用且等待场景
最好的方案应该是:如果线程要求的条件(转出账本和转入账本同在文件架上)不满足,则线程阻塞自己,进入等待状态;当线程要求的条件(转出账本和转入账本同在文件架上)满足后,通知等待的线程重新执行。其中,使用线程阻塞的方式就能避免循环等待消耗 CPU 的问题。
生活案例
就医流程 -> 等待-通知机制
1.患者到就诊门口分诊,类似于线程要去 获取互斥锁 ;当患者被叫到时,类似线程 已经获取到锁了 。 2.大夫让患者去做检查(缺乏检测报告不能诊断病因),类似于线程要求的 条件没有满足 。患者去做检查,类似于线程 进入等待状态 ;然后大夫叫下一个患者,这个步骤我们在前面的等待 - 通知机制中忽视了,这个步骤对应到程序里,本质是线程释放持有的互斥锁。 3.患者做完检查,类似于线程要求的 条件已经满足 ;患者拿检测报告重新分诊,类似于线程需要 重新获取互斥锁 ,这个步骤我们在前面的等待 - 通知机制中也忽视了。
等待-通知机制概述
通过就医流程得出等待-通知机制的结论 一个完整的等待 - 通知机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。
等待-通知机制的java实现
synchronized实现等待-通知机制
Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法实现。 Java SDK包中的Lock锁+Condition实现
1.互斥锁与等待队列
互斥锁与等待队列 等待队列和互斥锁是一对一的关系 synchronized 实现互斥锁。同一时刻,只允许一个线程进入 synchronized 保护的临界区,其它线程进入该互斥锁对应的等待队列。
2.等待-通知中的等待
wait释放锁,进入条件变量的等待队列 在并发程序中,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,当调用 wait() 方法后,当前线程就会被阻塞,并且进入到条件变量的等待队列中,该等待队列是该互斥锁中对应条件变量对应的等待队列。 线程在进入条件变量的等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区了。 synchronized实现的等待-通知机制中只有一个条件变量
范式:在while中调用wait()
原因:notify()的通知时间和线程获取锁的时间之间不会重合
wait和notify关系
wait()、notify()、notifyAll() 这三个方法能够被调用的前提是已经获取了相应的互斥锁,所以我们会发现 wait()、notify()、notifyAll() 都是在 synchronized{}内部被调用的。 如果在 synchronized{}外部调用,或者锁定的 this,而用 target.wait() 调用的话,JVM 会抛出一个运行时异常:java.lang.IllegalMonitorStateException。 注意1:调用wait会立即释放锁,线程进入条件等待队列 注意2:调用notify不会立即释放锁,而是将notify后的代码执行完毕后释放锁。 两个注意事项决定通知和线程获取锁使条件变量不一定满足线程执行条件,所以才有编程范式:while(条件){Object.wait();}
子主题
3.等待-通知中的唤醒
等待-通知机制的通知唤醒 线程要求的条件满足时,Java 对象的 notify() 和 notifyAll() 方法通知条件变量中等待队列中的线程,告诉他条件曾经满足过。
notify只能确保条件曾经满足
1.线程重新执行需要重新获取锁
被通知的线程要想重新执行,仍然需要获取到互斥锁(因为曾经获取的锁在调用 wait() 时已经释放了)。
2.notify()的通知时间和线程获取锁的时间之间不会重合
为什么是曾经满足过呢? 1.notify()在通知后,并不会立即释放锁,而是会执行完notify后面的代码。 2.当调用notify()的线程释放锁,等待队列中的线程才开始获取锁。 综上所述 :被通知线程的执行时间点和通知的时间点基本上不会重合,在线程重新获取到锁使,条件可能已经不满足。所以才有了将wait 在while中执行,这条最佳实践。 while(条件变量){ Object.wait(); }
wait和notify关系
wait()、notify()、notifyAll() 这三个方法能够被调用的前提是已经获取了相应的互斥锁,所以我们会发现 wait()、notify()、notifyAll() 都是在 synchronized{}内部被调用的。 如果在 synchronized{}外部调用,或者锁定的 this,而用 target.wait() 调用的话,JVM 会抛出一个运行时异常:java.lang.IllegalMonitorStateException。 注意1:调用wait会立即释放锁,线程进入条件等待队列 注意2:调用notify不会立即释放锁,而是将notify后的代码执行完毕后释放锁。 两个注意事项决定通知和线程获取锁使条件变量不一定满足线程执行条件,所以才有编程范式:while(条件){Object.wait();}
notify和notifyALL的选择
不使用notify:notify和wait一一对应
1.假设我们有资源 A、B、C、D,线程 1 申请到了 AB,线程 2 申请到了 CD, 此时线程 3 申请 AB,会进入等待队列(AB 分配给线程1,线程 3 要求的条件不满足),线程 4 申请 CD 也会进入等待队列。 2.我们再假设之后线程 1 归还了资源 AB,如果使用 notify() 来通知等待队列中的线程,有可能被通知的是线程 4,但线程 4 申请的是 CD,所以此时线程 4 还是会继续等待,而真正该唤醒的线程 3 就再也没有机会被唤醒了。 3.因为wait和notify是一一对应的关系,所以除非经过深思熟虑,否则尽量使用 notifyAll()。 4.在资源调度类是单利模式下,所以它使用的是一把锁。他们在同一个等待队列中等待,所以使用notify()进行唤醒,可能线程1准备好了,但是他随机的从等待队列中挑选一个等待线程,可能是线程4,但是线程4的条件并没有满足,所以线程4会再次进入等待。由notify()和等待队列中的线程是一一对应的,所以已经使用掉一个notify(),那么导致等待队列中的线程1始终无法完成条件验证
尽量使用notifyAll
notifyAll() 和 notify() 二者的区别: notify() 是会随机地通知等待队列中的一个线程,而notifyAll() 会通知等待队列中的所有线程。从感觉上来讲,应该是 notify() 更好一些,因为即便通知所有线程,也只有一个线程能够进入临界区。 但那所谓的感觉往往都蕴藏着风险,实际上使用 notify() 也很有风险,它的风险在于可能导致某些线程永远不会被通知到。
4.代码示例:等待-通知机制实现更好的资源分配
1.线程要求的条件:转出账户和转入账户都没有被分配过。 2.何时等待:线程要求的条件不满足就等待。 3.何时通知:当有线程释放账户时就通知。 需要注意的是我们使用了: while(条件不满足) { wait(); } 利用这 种范式可以解决上面提到的条件曾经满足过这个问题 。因为当 wait() 返回时,有可能条件已经发生变化了,曾经条件满足,但是现在已经不满足了 ,所以要重新检验条件是否满足。范式,意味着是经典做法,所以没有特殊理由不要尝试换个写法。后面在介绍“管程”的时候,我会详细介绍这个经典做法 的前世今生。 class Allocator { private List<Object> als; // 一次性申请所有资源 synchronized void apply( Object from, Object to){ // 经典写法 while(als.contains(from) || als.contains(to)){ try{ wait(); }catch(Exception e){ } } als.add(from); als.add(to); } // 归还资源 synchronized void free( Object from, Object to){ als.remove(from); als.remove(to); notifyAll(); } }
等待-通知的资源申请类
public class Allocator { private final List<Account> als=new LinkedList<Account>(); // 一次性申请所有资源 public synchronized void apply(Account from, Account to) { // 经典写法 while (als.contains(from) || als.contains(to)) { try { System.out.println("等待用户 -> "+from.getId()+"_"+to.getId()); wait(); } catch (Exception e) { //notify + notifyAll 不会来这里 System.out.println("异常用户 -> "+from.getId()+"_"+to.getId()); e.printStackTrace(); } } als.add(from); als.add(to); } // 归还资源 public synchronized void free(Account from, Account to) { System.out.println("唤醒用户 -> "+from.getId()+"_"+to.getId()); als.remove(from); als.remove(to); notifyAll(); } }
资源调用类
/** * 账户类 */ public class Account { //余额 private int balance = 0; public Account(int balance){ this.balance = balance; } //转账操作 public void transfer(Account target , int amt){ //申请资源,如果资源没有申请到,线程会阻塞在这里 Allocator.getInstance().apply(this,target); if (this.balance > amt){ this.balance -= amt; target.setBalance(target.getBalance()+amt); //释放资源 Allocator.getInstance().free(this,target); System.out.println("44444"); }else { //如果余额不满足条件,就释放资源 Allocator.getInstance().free(this,target); System.out.println("555555"); } } //修改余额 public void setBalance(int balance){ this.balance = balance; } //获取余额 public int getBalance(){ return this.balance; } }
启动类
public class Main { public static void main(String[] args) throws InterruptedException { Account from = new Account(1000); Account to = new Account(1000); CountDownLatch latch = new CountDownLatch(999); for (int i = 0; i < 999 ;i++){ new Thread(()->{ //latch.await(); latch.countDown(); System.out.println("2222"); from.transfer(to,123); }).start(); } //在计数器没有清零之前,会阻塞线程 latch.await(); // latch.countDown(); System.out.println("from "+ from.getBalance()); System.out.println("to "+ to.getBalance()); } }
小结
等待 - 通知机制是一种非常普遍的线程间协作的方式。工作中经常看到有人使用轮询的方式来等待某个状态,很多情况下都可以用等待 - 通知机制来优化。 Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法可以快速实现这种机制。 Java 语言的这种实现,背后的理论模型其实是管程。
思考题:wait() 和 sleep() 都能让当前线程挂起一段时间,它们的区别是什么?
思考题分析
语法层面对比
wait与sleep共性 : 1. wait、sleep 都会抛出InterruptedException 需要捕获处理 2. 都会让出CPU执行时间,等待再次调度。 3. wait(1000L),到时间自己醒过来或者到时间之前被其他线程唤醒,状态和sleep都是TIME_WAITING wait与sleep区别在于: 1. wait会释放所有锁资源,sleep不会释放锁资源. 2. wait只能在同步方法和同步块中使用,而sleep任何地方都可以. 3. wait是object顶级父类的方法,sleep则是Thread的方法 4.sleep方法调用的时候必须指定时间 5.wait()无参数需要唤醒,线程状态WAITING;wait(1000L),到时间自己醒过来或者到时间之前被其他线程唤醒,状态和sleep都是TIME_WAITING 2:wait需要被唤醒,sleep不需要 3:wait需要获取到监视器(锁),否则抛异常,sleep不需要获取锁
锁的层面分析
wait()方法与sleep()方法的区别: wait() 方法会释放对象的“锁标志”。当调用某一对象的wait()方法后,会使当前线程暂停执行,并将当前线程放入对象等待池中,直到调用了notify()方法后,将从对象等待池中移出任意一个线程并放入锁标志等待池中,只有锁标志等待池中的线程可以获取锁标志,它们随时准备争夺锁的拥有权。当调用了某个对象的notifyAll()方法,会将对象等待池中的所有线程都移动到该对象的锁标志等待池。 sleep() 方法需要指定等待的时间,它可以让当前正在执行的线程在指定的时间内暂停执行,进入阻塞状态,该方法既可以让其他同优先级或者高优先级的线程得到执行的机会,也可以让低优先级的线程得到执行机会。 但是sleep()方法不会释放“锁标志”,也就是说如果有synchronized同步块,其他线程仍然不能访问共享数据。
CPU执行层面分析
cpu执行权的验证方式:去操作系统里查看线程状态就可以了,线程都阻塞了,没法验证,也可以看线程的cpu使用率
wait和notify的使用场景
wait()是等待,等待在不确定的某个时间点完成。 sleep()是休眠,休眠必须在设定好的时间点完成。不是释放锁。 定闹钟,每天早上准时8点起床上班。它们的应用场景也不一样,wait()用来解决等待执行任务,提高效率;sleep()用来解决延迟执行任务,wait()适用于并发编程,而sleep()在UI编程中比较常用,用来忽悠用户,比如:扫描一个盘里面所有文件的进度条(1%,2%...90%,100%)或“请稍后...”之类的提示。 作者回复: 经常被大家忽视的是,锁会不会释放。原来大家都这么忽悠用户😂
关联知识
1.sleep
让程序慢下来,任务完成进度条
Thread.sleep(0),重新分配CPU执行权
2.wait唤醒后执行位置,需要while(条件){Object.wait();}重新检测条件是否满足
3.自旋锁的效率问题,适合并发量小的场景
看具体场景,并发量大的时候长时间拿不到锁会浪费cpu,并发量小的时候效率高。
4.tomcat处理并发问题
tomcat处理请求是如何处理并发问题的 为每一个请求分配一个独立的线程,网络处理支持bio和nio,servlet支持同步和异步,建议找本书专门看看
锁基于管程,适用同一进程内并发问题
07:宏观角度重新审视并发编程相关概念和理论
1.安全性
线程安全定义:本质上是正确性,正确性的含义是程序按照我们期望的执行
线程安全的案发现场
造成线程安全原因
原子性
可见性
有序性
共享数据且共享数据发生变化
解决问题的思路
不共享数据或者数据状态不发生变化
存在共享数据并且该数据会发生变化,就存在线程安全问题。如果做到不共享数据或者数据状态不发生变化,就可以保证线程的安全性。许多技术方案都是基于这个理论的,例如线程本地存储(Thread Local Storage,TLS)、不变模式等等
安全问题
数据竞争
定义:多线程中,至少有一个线程写数据
当多个线程同时访问同一数据,至少有一个线程会写这个数据的时候,如果不采取防护措施,就会导致并发 Bug,这种问题叫做数据竞争(Data Race)
数据竞争案例
public class Test { private long count = 0; void add10K() { int idx = 0; while(idx++ < 10000) { count += 1; // 多线程访问,都可以修改共享变量 } } }
造成问题:原子性、可见性
造成问题分析 共享变量没有受到保护 1.原子性问题:count++是组合操作,本身是非原子性。 2.可见性问题:count不保证可见性。 3.方法中只有共享变量是线程不安全的,其它的局部变量是线程私有的可以保证线程安全性。
竞态条件
定义:程序的执行结果依赖线程执行的顺序
竞态条件案例
锁粒度造成竞态条件
public class Test { private long count = 0; synchronized long get(){ return count; } synchronized void set(long v){ count = v; } void add10K() { int idx = 0; while(idx++ < 10000) { set(get()+1) } } } 共享变量的读写路径都由synchronized保护,它不存在数据竞争问题。 红色字体的代码存在竞态条件:虽然set和get使用了锁,但锁的范围是保护它们的方法。将两个方法组合是非原子性的,两个方法在获取锁时存在间隙。所以存在竞态条件 1.如果线程A释放get()的锁,线程B获取到get()锁,两个线程同时执行set()操作,则他们的执行结果只加了1. 2.如果线程A释放get()的锁,然后获取了set()锁,执行完写入操作。这是线程B则获取get()锁,接着获取set()锁,这时执行结果是正常。 在执行一个方法时,首先计算的入参,然后执行方法。 从上面两种情况的执行来看,执行结果依赖于线程的执行顺序。
造成问题:原子性
对数据的读取和写入操作,使用加锁保护,不存在数据竞争问题。但是锁的覆盖范围是方法,在方法和方法之间存在锁间隙。
标注
不存在可见性问题
add10的案例中没有可见性问题 执行count=1,没有读操作,没有可见性问题 没有可见性的原因:set(get()+1) ,get()+1 ,不同线程操作,都会在本地生成 局部变量 1 。没有对共享变量进行写操作
存在条件判断造成竞态条件
class Account { private int balance; // 转账 void transfer( Account target, int amt){ if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } 以转账为例: 转账操作里面有个判断条件——转出金额不能大于账户余额,但在并发环境里面,如果不加控制,当多个线程同时对一个账号执行转出操作时,就有可能出现超额转出问题。假设账户 A 有余额 200,线程 1 和线程 2 都要从账户 A 转出 150,有可能线程 1 和线程 2 同时执行到第 6 行,这样线程 1 和线程 2 都会发现转出金额 150 小于账户余额 200,于是就会发生超额转出的情况。
造成问题:原子性
一组操作没有锁的保护,无法保证操作的原子性
竞态条件一般模式
if (状态变量 满足 执行条件) { 执行操作 } 当某个线程发现状态变量满足执行条件后,开始执行操作;可是就在这个线程执行操作的时候,其他线程同时修改了状态变量,导致状态变量不满足执行条件了。 很多场景下,这个条件不是显式的 ,例如前面 addOne 的例子中,set(get()+1) 这个复合操作,其实就 隐式依赖 get() 的结果。 竞态条件的本质,线程的执行结果依赖线程的执行顺序。说白了,就是一组组合操作的原子性得不到保证,导致操作的中间状态外部可以访问。 显式竞态条件:1.存在条件判断,既存在if判断。 隐式竞态条件:1.set(get()+1) ,在这种情况下,set的执行结果依赖get()+1的执行结果。
锁解决问题
数据竞争和竞态条件问题,如何保证线程的安全性 这两类问题,都可以用 互斥 这个技术方案,而实现互斥的方案有很多,CPU 提供了相关的 互斥 指令,操作系统、编程语言也会提供相关的 API。 从逻辑上来看,我们可以统一归为:锁。
2.活跃性
定义:某个操作无法执行下去
常见问题
死锁
定义:线程之间等待获取对方资源,而又不释放自己的资源。
线程状态:blocked、waiting、timed_waiting
解决方案
1.锁设定超时时间
2.破坏占用且等待条件
方案:一次申请所有资源
技术:等待-通知机制
3.破坏资源占用
技术以及方案:Lock,自动释放占有的资源
4.破坏循环等待
方案:对锁对象的线性属性排序
活锁
定义:线程没有发生阻塞,仍然存在执行不下去的情况
线程状态:非阻塞
方案:尝试等待一个随机时间
采用该方案的技术:分布式一致性算法Raft
饥饿
定义:线程因无法访问所需资源而无法执行下去
导致饥饿原因:线程优先级、锁持有时间过长
如果线程优先级“不均”,在 CPU 繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。 在应用中为了实现任务隔离,设置不同类型的线程池分配任务,如果没有必要,则尽量不要设置线程优先级。
解决饥饿方案
方案一、是保证资源充足。保证CPU或IO充足
局限:很多场景下,资源的稀缺性是没办法解决
方案二、是公平地分配资源
使用范围较广:公平地分配资源,在并发编程里,主要是使用公平锁
这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。 公平地分配资源,在并发编程里,主要是使用公平锁。公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。
方案三、就是避免持有锁的线程长时间执行
局限:持有锁的线程执行的时间很难缩短
3.性能问题
问题原因:锁的过度使用可能导致串行化的范围过大,导致性能问题
“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了。反而会造成性能问题。 所以要尽量减少串行,串行对性能的影响?假设串行百分比是 5%,我们用多核多线程相比单核单线程能提速多少呢? 阿姆达尔(Amdahl)定律,代表了处理器并行运算之后效率提升的能力,它正好可以解决这个问题,具体公式如下: 公式里的 n 可以理解为 CPU 的核数,p 可以理解为并行百分比,那(1-p)就是串行百分比了,也就是我们假设的 5%。我们再假设 CPU 的核数(也就是 n)无穷大,那加速比 S 的极限就是 20。也就是说,如果我们的串行率是 5%,那么我们无论采用什么技术,最高也就只能提高 20 倍的性能。
解决方案
方案一:锁会带来性能问题,最好的方案是使用无锁的算法和数据结构
使用锁会带来性能问题,最好的方案自然就是使用无锁的算法和数据结构了 在这方面有很多相关的技术,例如线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、乐观锁等;Java 并发包里面的原子类也是一种无锁的数据结构;Disruptor 则是一个无锁的内存队列,性能都非常好……
1.TLS
2.Copy-on-write
3.乐观锁
4.原子类
方案二:减少锁持有的时间
减少锁持有的时间。 互斥锁本质上是将并行的程序串行化,增加并行度,一定要减少持有锁的时间。这个方案具体的实现技术也有很多,例如使用细粒度的锁,一个典型的例子就是 Java 并发包里的 ConcurrentHashMap,它使用了所谓分段锁的技术;还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。
1.分段锁
2.读写锁
4.性能指标
1.吞吐量
单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
2.延迟
从发出请求到收到响应的时间
延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
3.并发量
能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加
并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。
小结
并发宏观层面
我们在设计并发程序的时候,主要是从宏观出发,也就是要重点关注它的安全性、活跃性以及性能。 安全性方面要注意数据竞争和竞态条件, 活跃性方面需要注意死锁、活锁、饥饿等问题, 性能方面我们虽然介绍了两个方案,但是遇到具体问题,你还是要具体分析,根据特定的场景选择合适的数据结构和算法。
并发微观层面
并发编程是一个复杂的技术领域,微观上涉及到原子性问题、可见性问题和有序性问题,宏观则表现为安全性、活跃性以及性能问题。
思考题
Java 语言提供的 Vector 是一个线程安全的容器,有同学写了下面的代码,是否存在并发问题? void addIfNotExist(Vector v, Object o){ if(!v.contains(o)) { v.add(o); } } 答案:存在并发问题。
思考题分析
竞态问题分析
问题原因: 1.synchronized锁只能保证方法级别的原子性。 单独对方法操作可以保证原子性。但是两个方法组合操作无法保证原子性,因为两把锁之间存在锁间隙,中间状态处于可操作状态。 2.在vector的组合操作所在的方法中加锁,无法保证原子性。 synchronized void addIfNotExist(Vector vector, Object o){ if(!vector.contains(o)) {//进行这个判断同时可能执行了add操作 vector.add(o); } } 原因是synchronized使用this锁,但是vector使用的Vector对象的this锁,两个不同的锁,无法保证原子性。可以让当前对象继承Vector,使得锁的一致性。 3.竞态问题分析: 判断和写入操作是独立的,contains方法执行完,会释放锁,其他的线程可能获取锁,通过判断进入if中,这样两个线程同时进行add操作,可能会覆盖掉其他线程的操作。
关联知识
1.串行百分比计算
串行百分比的计算: 临界区都是串行的,非临界区都是并行的,用单线程执行临界区的时间/用单线程执行(临界区+非临界区)的时间就是串行百分比
2.活锁的解释
活锁是多个线程类似死锁的情况下,同时释放掉自己已经获取的资源,然后同时获取另外一种资源,又形成依赖循环,导致都不能执行下去 总的来说就是同时放弃,然后又重试竞争,最后死循环在里面了。
3.发程序与锁的因果关系
编写并发程序的初衷是为了提升性能,但在追求性能的同时由于多线程操作共享资源而出现了安全性问题,所以才用到了锁技术,一旦用到了锁技术就会出现了死锁,活锁等活跃性问题,而且不恰当的使用锁,导致了串行百分比的增加,由此又产生了性能问题,所以这就是并发程序与锁的因果关系。
4.ConcurrentHashMap锁的变化
ConcurrentHashMap 1.8后没有分段锁 syn + cas
5.调用方法,首先计算参数,然后调用方法
6.并发量增加延迟随之增加
高速上车多了,车就慢了。 1.车的速度不一样,慢车拖慢快车。 2.出口只有一个,大量数据请求会阻塞在出口。
08:管程:解决并发问题的万能钥匙
管程概念
定义:管理共享变量以及对共享变量的操作过程,使其支持并发
java中管程实现
synchronized 、wait、notify、noifyAll是管程三个组成部分
换个说法:java中synchronized、wait、notify、notifyAll它来源于管程。
Java 领域:管理类的成员变量和成员方法,保证类线程安全
管程相关
管程和信号量等价
信号量和管程是等价的,管程可以实现信号量,信号量也可以实现管程。但是管程更容易使用
管程:Montor,java中翻译为监视器。操作系统中称为管程
管程基于的操作系统知识:posix api实现管程
锁原理:参考《java并发编程的艺术》
管程
解决核心问题
在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。
互斥
解决并发思想
封装、互斥
管程解决互斥问题的思路,就是将共享变量及其对共享变量的操作统一封装起来。例如在方法上加synchronized,把对共享变量的所有操作封装起来。
java选择管程:管程和面向对象思想高度契合
案例:封装队列操作
例如: 管程 X 将共享变量 queue 队列和相关的操作入队 enq()、出队 deq() 都封装起来了; 线程 A 和线程 B 如果要访问共享变量 queue,只能通过调用管程提供的 enq()、deq() 方法来实现; enq()、deq() 保证互斥性,只允许一个线程进入管程。 管程模型和面向对象高度契合的,应该是Java 选择管程的原因。互斥锁用法,背后的模型就是管程。
同步
同步:线程之间的协作
方案;队列
锁的入口等待队列
作用:没有获取锁的线程进入队列等待
锁的条件队列
作用:解决线程见同步问题
入队
条件不满足,进入条件队列
出队
出队后进入入口队列,然后获取锁
概要
管程的中线程的入队和出队是管程的实现方做的,和我们的代码没有关系
java内置管程只有一个条件变量
API
wait()
MESA
编程范式:while(条件){wait()}
等待-通知流程
MESA 管程里面,T2 通知完 T1 后,T2 还是会接着执行,T1 并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是 notify() 不用放到代码的最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。
Hasen
notify在代码最后,执行后立即唤醒等待线程
等待-通知流程
Hasen 模型里面,要求 notify() 放在代码的最后,这样 T2 通知完 T1 后,T2 就结束了,然后 T1 再执行,这样就能保证同一时刻只有一个线程执行。
Hoare
调用notify,优先执行阻塞线程,然后回来执行当前线程
等待-通知流程
Hoare 模型里面,T2 通知完 T1 后,T2 阻塞,T1 马上执行;等 T1 执行完,再唤醒 T2,也能保证同一时刻只有一个线程执行。但是相比 Hasen 模型,T2 多了一次阻塞唤醒操作。
notify
使用条件
1.所有等待线程拥有相同的等待条件;
2.所有等待线程被唤醒后,执行相同的操作;
3.只需要唤醒一个线程。
案例:阻塞队列的入队和出队
需要满足以下三个条件: 1.所有等待线程拥有相同的等待条件; 2.所有等待线程被唤醒后,执行相同的操作; 3.只需要唤醒一个线程。 对于“队列不满”这个条件变量,其阻塞队列里的线程都是在等待“队列不满”这个条件,反映在代码里就是下面这 3 行代码。对所有等待线程来说,都是执行这 3 行代码,重点是 while 里面的等待条件是完全相同的。 while (队列已满){ // 等待队列不满 notFull.await(); } 所有等待线程被唤醒后执行的操作也是相同的,都是下面这几行: // 省略入队操作... //入队后,通知可出队 notEmpty.signal(); 同时也满足第 3 条,只需要唤醒一个线程。所以上面阻塞队列的代码,使用 signal() 是可以的。
模型图
在管程模型里,共享变量和对共享变量的操作是被封装起来的 示例图:1.外层的框代表封装。2.只有一个入口代表互斥。3.入口等待队列存放未获取锁的线程 当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。这个过程类似就医流程的分诊,只允许一个患者就诊,其他患者都在门口等待。 
管程分类
MESA
Hasen
Hoare
局限
java管程不能垮进程使用
小结:MESA模型
Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量。  Java 内置的管程方案(synchronized)使用简单,synchronized 关键字修饰的代码块,在编译期会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量; Java SDK 并发包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员自己进行加锁和解锁操作。 并发编程里两大核心问题——互斥和同步,都可以由管程来帮你解决。
java内置条件队列支持单个条件变量
SDK条件队列支持多个条件变量
思考题
wait() 方法,在 Hasen 模型和 Hoare 模型里面,都是没有参数的,而在 MESA 模型里面,增加了超时参数,你觉得这个参数有必要吗?
思考题分析
超时时间,防止死锁、活锁,提高代码的健壮性
mesa模型中,调用wait会使线程进入等待队列,wait操作设置超时时间,超时则从条件变量对应的等待队列进入入口等待队列,尝试获取锁【是否有机会被执行依赖于调度算法】。可以防止bug代码,造成线程永远不会唤醒,既造成死锁问题。 在MESA模型中,高优先级的线程和低优先级的线程同时从条件变量的等待队列中进入入口等待队列,在条件满足情况下,高优先级的线程总是比低优先级线程获取锁的几率高,低优先级可能造成线程饥饿。 另外,在使用自旋的方式验证条件变量,如果不做超时参数的设置就可能会导致曾经满足的条件此后都不满足而一直循环,造成线程饥饿。
关联知识
管程
hasen、hoare、mesa 线程同步机制
1.hasen 是执行完,再去唤醒另外一个线程。能够保证线程的执行。
2.hoare,是中断当前线程,唤醒另外一个线程,执行完再去唤醒,够保证完成。
3.mesa是进入等待队列,不一定有机会能够执行。
小结
hasen 的策略是优先执行持有资源的操作,hoare优先执行被阻塞的操作,mesa有机会两个都同时执行,但也有可能被阻塞的不被执行
监视器(锁)的获取流程
管程是一种概念,任何语言都可以通用。
获取锁的流程解析
1.管程是一种概念,任何语言都可以通用。 2.在java中,每个加锁的对象都绑定着一个管程(监视器) 3.线程访问加锁对象,就是去拥有一个监视器的过程。如一个病人去门诊室看医生,医生是共享资源,门锁锁定医生,病人去看医生,就是访问医生这个共享资源,门诊室其实是监视器(管程)。 4.所有线程访问共享资源,都需要先拥有监视器。就像所有病人看病都需要先拥有进入门诊室的资格。 5.监视器至少有两个等待队列。一个是进入监视器的等待队列一个是条件变量对应的等待队列。后者可以有多个。就像一个病人进入门诊室诊断后,需要去验血,那么它需要去抽血室排队等待。另外一个病人心脏不舒服,需要去拍胸片,去拍摄室等待。 6.监视器要求的条件满足后,位于条件变量下等待的线程需要重新在门诊室门外排队,等待进入监视器。就像抽血的那位,抽完后,拿到了化验单,然后,重新回到门诊室等待,然后进入看病,然后退出,医生通知下一位进入。 总结起来就是,管程就是一个对象监视器。任何线程想要访问该资源,就要排队进入监控范围。进入之后,接受检查,不符合条件,则要继续等待,直到被通知,然后继续进入监视器。
管程的两种实现方式
synchronized+wait、notify、notifyAll
notify,线程执行动作
一个synchronized锁对应两个队列,一个是就绪队列,一个是条件变量对应的等待队列,wait操作是把当前线程放入条件变量的等待队列中,而notifyall是将条件变量等待队列中的所有线程唤醒到就绪队列(入口等待队列)中,实际上哪个线程执行由jvm操作。
lock+内部的condition
await和single,依然使用while范式
await()和single()依然使用while范式的原因:MESA模型特性,多线程中线程唤醒和线程获取到锁时间不一致性,存在线程唤醒时条件满足,但是获取到锁时条件不满足。 使用if无法检测到条件不满足的情况。使用while是最稳的方式。
子主题
概述
管程的组成:锁和大于等于0个条件变量。 java用两种方式实现了管程: ①synchronized+wait、notify、notifyAll,只支持一个条件变量,即wait,调用wait时会将其加到等待队列中,被notify时,会随机通知一个线程加到获取锁的等待队列中, ②lock+内部的condition,condition支持中断和增加了时间的等待,lock需要自己进行加锁解锁,更加灵活,两个都是可重入锁,但是lock支持公平和非公平锁,synchronized支持非公平锁。 synchronized+wait/notify/notifyAll,语法层面 Lock+await/signal/signalAll,是API层面。 AQS和ArrayBlockingQueue源码中,主队列和条件队列是MESA模型的实现方式。
JUC实现原理
AQS和ArrayBlockingQueue源码中,主队列和条件队列是MESA模型的实现方式。
ArrayBlockQueue源码也是用while 循环判断
AQS使用到了MESA原理
使用notify给wait加上超时时间,防止因为线程优先级导致有些一直不被唤醒。使用lock也可以参考
等待-通知案例
使用while,而不使用if
class BoundedBuffer { final Lock lock = new ReentrantLock(); final Condition notFull = lock.newCondition(); final Condition notEmpty = lock.newCondition(); final Object[] items = new Object[100]; int putptr, takeptr, count; public void put(Object x) throws InterruptedException { lock.lock(); try { while (count == items.length) 这里使用而不使用if。 //if(count == items.length ) 在多线程条件下,造成通知时条件满足,但是线程获取锁开始执行时,条件已经被其他的线程破坏了。 notFull.await(); items[putptr] = x; if (++putptr == items.length) putptr = 0; ++count; notEmpty.signal(); } finally { lock.unlock(); } } public Object take() throws InterruptedException { lock.lock(); try { while (count == 0) notEmpty.await(); Object x = items[takeptr]; if (++takeptr == items.length) takeptr = 0; --count; notFull.signal(); return x; } finally { lock.unlock(); } } }
死锁排查
jstack查看
lock condition底层使用park实现
LockSupport中的park() 和 unpark() 的作用分别是阻塞线程和解除阻塞线程。 lock,condition底层调用park实现。
生产中会限制线程池的大小,不会限定队列的大小
09、10和11:java线程相关知识(声明周期、线程数以及内部执行方式)
09 线程生命周期
线程是操作系统的概念
java线程和操作系统一一对应
在 Java 领域,实现并发程序的主要手段就是多线程。 线程是操作系统里的一个概念,Java 语言里的线程本质上就是操作系统的线程,它们是一一对应的。
操作系统线程的周期
对于学习操作系统层面的线程周期,只要能搞懂生命周期中各个节点的状态转换机制就可以了。 不同的开发语言对操作系统的线程进行不同的封装,但对于生命周期,基本上是雷同的。
操作系统线程周期
五态模型,重点是线程状态转换
1.初始状态
线程已经被创建,但是还不允许分配 CPU 执行
1.初始状态,指的是线程已经被创建,但是还不允许分配 CPU 执行。这个状态属于编程语言特有的,这里的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建。
2.可运行状态
线程可以分配 CPU 执行
2.可运行状态,指的是线程可以分配 CPU 执行。在这种状态下,真正的操作系统线程已经被成功创建了,所以可以分配 CPU 执行。
3.运行状态
线程分配到CPU执行时间
3.当有空闲的 CPU 时,操作系统会将其分配给一个处于可运行状态的线程,被分配到 CPU 的线程的状态就转换成了运行状态。
4.休眠状态
释放CPU执行权
4.运行状态的线程如果调用一个阻塞的 API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到休眠状态,同时释放 CPU 使用权,休眠状态的线程永远没有机会获得 CPU 使用权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态。
5.终止状态
线程执行完或者出现异常
5.线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。
状态的改变与CPU息息相关
JAVA的线程周期
java对通用线程周期的合并和细化
合并
可运行状态
运行状态
细化:休眠状态
BLOCKED
WAITING
TIMED_WAITING
六态模型
1.NEW(初始化状态)
2.RUNNABLE(可运行 / 运行状态)
3.BLOCKED(阻塞状态)
4.WAITING(无时限等待)
5.TIMED_WAITING(有时限等待)
TIMED_WAITING 和 WAITING 状态的区别,仅仅是触发条件多了超时参数
Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 是一种状态,对应操作系统中休眠状态。只要 Java 线程处于这三种状态之一,那么这个线程就永远没有 CPU 的使用权。BLOCKED、WAITING、TIMED_WAITING 可以理解为线程导致休眠状态的三种原因。
BLOCKED、WAITING、TIMED_WAITING 可以理解为线程导致休眠状态的三种原因。
6.TERMINATED(终止状态)
线程切换
RUNNABLE 与 BLOCKED 的状态转换
只有在线程等待synchronized 的隐式锁,才会触发这种转换。
只有在线程等待synchronized 的隐式锁,才会触发这种转换。 synchronized 修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从 RUNNABLE 转换到 BLOCKED 状态。而当等待的线程获得 synchronized 隐式锁时,就又会从 BLOCKED 转换到 RUNNABLE 状态。 线程调用阻塞式 API 时,是否会转换到 BLOCKED 状态呢? 在操作系统层面,线程是会转换到休眠状态的,但是在 JVM 层面,Java 线程的状态不会发生变化,也就是说 Java 线程的状态会依然保持 RUNNABLE 状态。JVM 层面并不关心操作系统调度相关的状态,因为在 JVM 看来,等待 CPU 使用权(操作系统层面此时处于可执行状态)与等待 I/O(操作系统层面此时处于休眠状态)没有区别,都是在等待某个资源,所以都归入了 RUNNABLE 状态。 我们平时所谓的 Java 在调用阻塞式 API 时,线程会阻塞,指的是操作系统线程的状态,并不是 Java 线程的状态。
RUNNABLE 与 WAITING 的状态转换
场景一:获得 synchronized 隐式锁的线程,调用无参数 Object.wait() 方法
场景二:调用无参数的 Thread.join() 方法
第二种场景,调用无参数的 Thread.join() 方法。其中的 join() 是一种线程同步方法, 这里有线程A和线程B,在线程A中调用线程B的join()方法,B.join()。线程A会等待线程B执行完,这个时候线程A的状态会从RUNNABLE 转换到 WAITING。 当线程B执行完后,线程A状态又会从 WAITING 状态转换到 RUNNABLE。
场景三:调用 LockSupport.park() 方法
第三种场景,调用 LockSupport.park() 方法。LockSupport 对象, Java 并发包中的锁,都是基于它实现的。调用 LockSupport.park() 方法,当前线程会阻塞,线程的状态会从 RUNNABLE 转换到 WAITING。调用 LockSupport.unpark(Thread thread) 可唤醒目标线程,目标线程的状态又会从 WAITING 状态转换到 RUNNABLE。
RUNNABLE 与 TIMED_WAITING 的状态转换
场景一:调用带超时参数的 Thread.sleep(long millis) 方法;
场景二:调用带超时参数的 Object.wait(long timeout) 方法;
场景二:获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法;
场景三:调用带超时参数的 Thread.join(long millis) 方法;
场景四:调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法;
场景五:调用带超时参数的 LockSupport.parkUntil(long deadline) 方法。
NEW 到 RUNNABLE 状态
NEW
创建线程对象
RUNNABLE
线程对象调用start()
案例
Java 刚创建出来的 Thread 对象就是 NEW 状态,而创建 Thread 对象主要有两种方法。一种是继承 Thread 对象,重写 run() 方法。示例代码如下: // 自定义线程对象 class MyThread extends Thread { public void run() { // 线程需要执行的代码 ...... } } // 创建线程对象 MyThread myThread = new MyThread(); 一种是实现 Runnable 接口,重写 run() 方法,并将该实现类作为创建 Thread 对象的参数。示例代码如下: // 实现Runnable接口 class Runner implements Runnable { @Override public void run() { // 线程需要执行的代码 ...... } } // 创建线程对象 Thread thread = new Thread(new Runner()); NEW 状态的线程,不会被操作系统调度,因此不会执行。Java 线程要执行,就必须转换到 RUNNABLE 状态。从 NEW 状态转换到 RUNNABLE 状态很简单,只要调用线程对象的 start() 方法就可以了,示例代码如下: MyThread myThread = new MyThread(); // 从NEW状态转换到RUNNABLE状态 myThread.start();
RUNNABLE 到 TERMINATED 状态
线程执行完 run() 方法后,会自动转换到 TERMINATED 状态,当然如果执行 run() 方法的时候异常抛出,也会导致线程终止。
线程中断
stop() 和 interrupt()区别
stop()
杀死线程,Lock不会释放锁
stop() 方法会杀死线程,如果线程持有 ReentrantLock 锁,被 stop() 的线程并不会自动调用 ReentrantLock 的 unlock() 去释放锁,那其他线程就再也没机会获得 ReentrantLock 锁。造成线程死锁。 所以该方法就不建议使用了,类似的方法还有 suspend() 和 resume() 方法,这两个方法同样也都不建议使用了。
synchronized会释放锁。
interrupt()
线程中断
interrupt() 方法仅仅是通知线程,线程有机会执行一些后续操作,同时也可以无视这个通知。被 interrupt 的线程,收到通知的方式,一种是捕获异常,另一种是主动检测。
相应中断方式
主动检测
如果线程处于 RUNNABLE 状态,并且没有阻塞在某个 I/O 操作上,例如中断计算圆周率的线程 A,这时就得依赖线程 A 主动检测中断状态了。如果其他线程调用线程 A 的 interrupt() 方法,那么线程 A 可以通过 isInterrupted() 方法,检测是不是自己被中断了。
抛出异常
异常通知,线程状态
线程休眠状态 -> RUNNABLE
1. 当一个线程处于休眠状态 当线程 A 处于 WAITING、TIMED_WAITING 状态时,如果其他线程调用线程 A 的 interrupt() 方法,会使线程 A 返回到 RUNNABLE 状态,同时线程 A 的代码会触发 InterruptedException 异常。 WAITING、TIMED_WAITING 状态的触发条件,都是调用了类似 wait()、join()、sleep() 这样的方法,我们看这些方法的签名,发现都会 throws InterruptedException 这个异常。这个异常的触发条件就是:其他线程调用了该线程的 interrupt() 方法。
线程处于RUNNABLE 状态
2. 当线程处于RUNNABLE 状态 线程 A 处于 RUNNABLE 状态时,并且阻塞在 java.nio.channels.InterruptibleChannel 上时,如果其他线程调用线程 A 的 interrupt() 方法,线程 A 会触发 java.nio.channels.ClosedByInterruptException 这个异常;而阻塞在 java.nio.channels.Selector 上时,如果其他线程调用线程 A 的 interrupt() 方法,线程 A 的 java.nio.channels.Selector 会立即返回。
思考题
当前线程被中断之后,退出while(true),你觉得这段代码是否正确呢? Thread th = Thread.currentThread(); while(true) { if(th.isInterrupted()) { break; } // 省略业务代码无数 try { Thread.sleep(100); }catch (InterruptedException e){ e.printStackTrace(); } } 重点:响应中断后,中断标志重置
思考题分析
中断响应,中断标识重置
捕获中断异常,恢复中断标识
代码示例
无线循环,线程在sleep从timed_waiting状态切换到running状态,然后相应中断,抛出一个InterruptedException异常,try catch捕捉此异常,,中断标识会自动清除掉! Thread th = Thread.currentThread(); while(true) { if(th.isInterrupted()) { break; } // 省略业务代码无数 try { Thread.sleep(100); }catch (InterruptedException e){ Thread.currentThread().interrupt(); 重新设置中断标识。在主动检测处检测到中断,跳出循环。 e.printStackTrace(); } }
关联知识
interrupt,响应中断后清除中断标识原因
interrupt是中断的意思,在单片机开发领域,用于接收特定的事件,从而执行后续的操作。Java线程中,(通常)使用interrupt作为线程退出的通知事件,告知线程可以结束了。 interrupt不会结束线程的运行,在抛出InterruptedException后会清除中断标志(代表可以接收下一个中断信号了),所以我想,interrupt应该也是可以类似单片机一样作为一种通知信号的,只是实现通知的话。 因InterruptedException退出同步代码块会释放当前线程持有的锁,所以相比外部强制stop是安全的(已手动测试)。sleep、join等会抛出InterruptedException的操作会立即抛出异常,wait在被唤醒之后才会抛出异常(就像阻塞一样,不被打扰)。 I/O阻塞在Java中是RUNNING,并发包中的lock是WAITING状态。
表示可以接受下一个中断标志
interrupt不会结束线程的运行,在抛出InterruptedException后会清除中断标志(代表可以接收下一个中断信号了),interrupt应该也是可以类似单片机一样作为一种通知信号的,只是实现通知的话。
interrupt()比stop()安全
因InterruptedException退出同步代码块会释放当前线程持有的锁,所以相比外部强制stop是安全的(已手动测试)。sleep、join等会抛出InterruptedException的操作会立即抛出异常,wait在被唤醒之后才会抛出异常(就像阻塞一样,不被打扰)。
1.interrupt抛出中断异常,可以释放锁,
2.自定义中断时机,对中断做出补偿,保证数据的一致性
I/O阻塞在Java中是RUNNING,并发包中的lock是WAITING状态。
jvm中blocked状态的线程和waitting状态的线程的区别
1.处在不同的队列
2.blocked不能相应中断,waiting可以相应中断。
3.两个中断对应操作系统都是休眠状态,都不能获得CPU执行权。
阻塞式API
1.阻塞式的读文件,读网络数据都是。例如:java里的bio,例如读文件写socket
2.Java调用阻塞API时,Java层面是runnable,不占用CPU。此时此线程在操作系统是阻塞状态。
3.不希望线程在阻塞调用时相应中断,而退出线程。可以在异常捕获出重置中断标识,在中断检测的地方退出线程
小结
诊断并发bug:dump、jstack、VisualVM
Java 线程的各种状态以及生命周期对于诊断多线程 Bug 非常有帮助,多线程程序很难调试,出了 Bug 基本上都是靠日志,靠线程 dump 来跟踪问题,分析线程 dump 的一个基本功就是分析线程状态,大部分的死锁、饥饿、活锁问题都需要跟踪分析线程的状态。 通过 jstack 命令或者Java VisualVM这个可视化工具将 JVM 所有的线程栈信息导出来,完整的线程栈信息不仅包括线程的当前状态、调用栈,还包括了锁的信息。
10 创建多少线程?
Java 领域,实现并发程序的主要手段就是多线程,使用多线程比较简单,但是使用多少个线程却是个困难的问题。 工作中,经常有人问,“各种线程池的线程数量调整成多少是合适的?”或者“Tomcat 的线程数、Jdbc 连接池的连接数是多少?”等等。那我们应该如何设置合适的线程数呢? 要解决这个问题,首先要分析以 下两个问题: 1.为什么要使用多线程 2.多线程的应用场景有哪些
两方面分析
为什么使用多线程
使用多线程原因
提升程序性能
使用多线程,本质上就是提升程序性能
核心度量标准
提升性能之前,首要问题是:如何度量性能。 度量性能的指标有很多,但是有两个指标是最核心的,它们就是延迟和吞吐量。
延迟
延迟指的是发出请求到收到响应这个过程的时间;延迟越短,意味着程序执行得越快,性能也就越好。
吞吐量
吞吐量指的是在单位时间内能处理请求的数量;吞吐量越大,意味着程序能处理的请求越多,性能也就越好。
QPS是吞吐量的一种度量方法
二者的内在联系
这两个指标内部有一定的联系(同等条件下,延迟越短,吞吐量越大)。它们隶属不同的维度(一个是时间维度,一个是空间维度),不能互相转换。
提升性能,从度量的角度,主要是降低延迟,提高吞吐量。这是使用多线程的主要目的
多线程应用场景
概念:多线程使用场景
降低延迟
提高吞吐量
解决思路
算法
硬件性能发挥极致
那计算机中的硬件主要是两类:一个是 I/O,一个是 CPU。在并发编程领域,提升性能本质上就是提升硬件的利用率,具体点来说,就是提升 I/O 的利用率和 CPU 的利用率。
硬件主要分两类
I/O
CPU
解决CPU和I/O综合利用问题
操作系统系统解决的是单一的硬件设备利用率问题。而并发编程需要解决CPU和I/O设备综合利用率的问题。这是操作系统没有办法完美解决的问题。操作系统提供给我们的解决方案是:多线程。 线程是操作系统最小调度单位,合理的利用多线程可以一定程度上解决CPU和I/O设备综合利用率的问题。 案例: 操作系统解决单一设备利用率 例如操作系统已经解决了磁盘和网卡的利用率问题,利用中断机制还能避免 CPU 轮询 I/O 状态,也提升了 CPU 的利用率。但是操作系统解决硬件利用率问题的对象往往是单一的硬件设备,
单线程CPU和I/O利用率
如果只有一个线程,执行 CPU 计算的时候,I/O 设备空闲;执行 I/O 操作的时候,CPU 空闲,所以 CPU 的利用率和 I/O 设备的利用率都是 50%。  交替交叉执行就是在单线程中,执行i/o操作和执行CPU操作花费的时间比相同。
两个线程CPU和I/O利用率
当线程 A 执行 CPU 计算的时候,线程 B 执行 I/O 操作;当线程 A 执行 I/O 操作的时候,线程 B 执行 CPU 计算,这样 CPU 的利用率和 I/O 设备的利用率就都达到了 100%。 这里的100%指的是两个线程总的CPU和I/O运行时间比达到1  通过上面的图示,很容易看出:单位时间处理的请求数量翻了一番,也就是说吞吐量提高了 1 倍。此时可以逆向思维一下,如果 CPU 和 I/O 设备的利用率都很低,那么可以尝试通过增加线程来提高吞吐量。
利用率:CPU运行时间/ I/O运行时间
线程是操作系统最小调度单位,发挥硬件优势,就是合理利用多线程。
并发编程关系密切相关
单核时代CPU作用
主要平衡CPU和I/O
单核时代,多线程主要就是用来平衡 CPU 和 I/O 设备的。 如果程序只有 CPU 计算,而没有 I/O 操作的话,多线程不但不会提升性能,还会使性能变得更差,原因是增加了线程切换的成本。
只有CPU计算,增加上下文切换
有I/O,多线程增加CPU使用率
多核时代CPU作用
降低响应时间
多核时代,纯计算型的程序也可以利用多线程来提升性能。因为利用多核可以降低响应时间。 可以将任务拆分到不同线程上,提升CPU利用率。 例子: 计算 1+2+… … +100 亿的值,如果在 4 核的 CPU 上利用 4 个线程执行,线程 A 计算 [1,25 亿),线程 B 计算 [25 亿,50 亿),线程 C 计算 [50,75 亿),线程 D 计算 [75 亿,100 亿],之后汇总,那么理论上应该比一个线程计算 [1,100 亿] 快将近 4 倍,响应时间能够降到 25%。一个线程,对于 4 核的 CPU,CPU 的利用率只有 25%,而 4 个线程,则能够将 CPU 的利用率提高到 100%。
具体应用场景分析
I/O密集型
定义:任务【单线程】中有I/O操作
程序执行是以任务为单位,任务依托于线程执行,在单线程中CPU 计算和 I/O 操作交叉执行。I/O 操作执行的时间相对于 CPU 计算来说都非常长,这种场景我们一般都称为 I/O 密集型计算; 交叉执行:单线程中,I/O和CPU交叉执行的含义是在一个任务中既有I/O操作又有CPU操作,I/O操作执行完后执行CPU操作。I/O操作的耗时比CPU耗时长的多。所以这样的任务是I/O密集型操作。
I/O耗时比CPU耗时长
执行特点:I/O操作和CPU操作交叉执行
交叉执行
交叉执行:单线程中,I/O和CPU交叉执行的含义是在一个任务中既有I/O操作又有CPU操作,I/O操作执行完后执行CPU操作。I/O操作的耗时比CPU耗时长的多。所以这样的任务是I/O密集型操作。I/O操作的时间单位是毫秒(ms),CPU的时间单位是纳秒(ns)。1s=1000ms,1ms = 1000us,1us = 1000ns
I/O密集型创建线程数分析
根据CPU 计算和 I/O 操作的耗时比值
案例:根据比值创建线程数
I/O 密集型的计算场景,如果 CPU 计算和 I/O 操作的耗时是 1:1,那么 2 个线程是最合适的。如果 CPU 计算和 I/O 操作的耗时是 1:2,那么创建 3 个线程, 如下图所示:CPU 在 A、B、C 三个线程之间切换,对于线程 A,当 CPU 从 B、C 切换回来时,线程 A 正好执行完 I/O 操作。这样 CPU 和 I/O 设备的利用率都达到了 100%。 
I/O 密集型的创建线程数公式
单核
最佳线程数 =1 +(I/O 耗时 / CPU 耗时)
对于 I/O 密集型计场景,最佳的线程数是与程序中 CPU 计算和 I/O 操作的耗时比相关的,我们可以总结出这样一个公式: 最佳线程数 =1 +(I/O 耗时 / CPU 耗时) 我们令 R=I/O 耗时 / CPU 耗时,综合上图,可以这样理解:当线程 A 执行 IO 操作时,另外 R 个线程正好执行完各自的 CPU 计算。这样 CPU 的利用率就达到了 100%。 上面这个公式是针对单核 CPU 的,至于多核 CPU,也很简单,只需要等比扩大就可以了,计算公式如下: 最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]
多核
最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]
CPU密集型
定义:计算大部分场景下都是纯 CPU 计算。
CPU密集型任务创建线程数分析
多线程本质提高多核CPU利用率
CPU 密集型计算,多线程本质上是提升多核 CPU 的利用率。
案例:多线程提高多核CPU利用率
对于一个 4 核的 CPU,每个核一个线程,理论上创建 4 个线程就可以了,再多创建线程也只是增加线程切换的成本。所以,对于 CPU 密集型的计算场景,理论上“线程的数量 =CPU 核数”就是最合适的。在工程上,线程的数量一般会设置为“CPU 核数 +1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率。
创建线程个数:CPU核数+1
理论值使用范围:单个服务器只部署一个应用
小结:创建线程数,确定CPU和I/O时间比值是关键
线程数不是越多越好,但是设置多少是合适的,其实只要把握住一条原则就可以了,这条原则就是将硬件的性能发挥到极致。针对 CPU 密集型和 I/O 密集型计算场景给出了理论上的最佳公式,这些公式背后的目标其实就是将硬件的性能发挥到极致。 对于 I/O 密集型计算场景,I/O 耗时和 CPU 耗时的比值是一个关键参数,这个参数是未知的,而且是动态变化的,所以工程上需要估算这个参数,通过各种不同场景下的压测来验证我们的估计。工程上,原则是将硬件的性能发挥到极致,所以压测时,重点关注 CPU、I/O 设备的利用率和性能指标(响应时间、吞吐量)之间的关系。
重点关注:响应时间和吞吐量两个指标
思考题:设置线程数的经验值,是否可靠
根据经验将I/O 密集型应用最佳线程数应该为:2 * CPU 的核数 + 1,你觉得这个经验值合理吗?
思考题分析
1.任务分I/O密集型和CPU密集型,不同的任务类型创建线程数不同
2.经验值可以作为线程数的起始值,但具体的需要压测得到I/O和CPU时间比值
任务就分为CPU密集型和IO密集型的,IO密集型需要进行测试和分析而得到结果,自己预想的和测得的数据差别会比较大。比如IO/CPU的比率很大,比如10倍。2核情况下,较佳配置:2*(1+10)=22个线程,而2*CPU核数+1 = 5,这两个差别就很大了。最终的配置比需要实际测试才能得到。
关联知识
1.请求量过大,单纯的而分配线程无法处理请求
当应用来的请数量过大,此时线程池的线程已经不够使用,排队的队列也已经满了,那么后面的请求就会被丢弃掉,如果这是一个更新数据的请求操作,那么就会出现数据更新丢失。 解决思路:单机有瓶颈,就分布式。数据库有瓶颈,就分库分表分片
问题
问题1:线程池队列已满,请求抛弃如果是数据库更新服务,造成数据丢失
问题2:单机瓶颈
单机有瓶颈,就分布式。数据库有瓶颈,就分库分表分片
2.物理核心和逻辑核心
一、在4核8线程的处理器使用Runtime.getRuntime().availableProcessors()结果是8,得到的是逻辑核心数。
二、工作中的CPU核数是按照逻辑核心
三、超线程技术
超线程技术属于硬件层面上的并发,从cpu硬件来看是一个物理核心有两个逻辑核心,但因为缓存、执行资源等存在共享和竞争,所以两个核心并不能并行工作。 超线程技术统计性能提升大概是30%左右,并不是100%。另外,不管设置成4还是8,现代操作系统层面的调度应该是按逻辑核心数,也就是8来调度的(除非禁用超线程技术)。所以我觉得这种情况下,严格来说,4和8都不一定是合适的,具体情况还是要根据应用性能和资源的使用情况进行调整。 工作中都是按照逻辑核数来的,理论值和经验值只是提供个指导,实际上还是要靠压测。
3.一般线程中运行的任务都是I/O密集型
4.I/O相关
1.测试CPU和I/O耗时比
使用amp工具
apm是方法级别,只关注io花了多少时间就可以了,用时间减去它就是cpu时间
2.I/O是性能瓶颈优化建议
io密集或者cpu密集很难在定量的维度上反应出性能瓶颈,而且公式上忽略了线程数增加带来的cpu消耗,性能优化还是要定量比较好, 比如io已经成为了瓶颈,增加线程或许带来不了性能提升,这个时候可以考虑用cpu换取带宽,压缩数据,或者逻辑上少发送一些。如果没有特殊需要,可以选择常用的默认值作为参考,具体的参数选择在经过各种测试后确定。
I/O成为瓶颈,CPU使用率就上不去,增加线程只会增加上下文切换
考虑使用CPU换取带宽:压缩数据
性能瓶颈很难量化,需要通过测试设置合适的值
3.I/O密集型,CPU周期问题
“最佳线程数 =1 +(I/O 耗时 / CPU 耗时) 我们令 R=I/O 耗时 / CPU 耗时,综合上图,可以这样理解:当线程 A 执行 IO 操作时,另外 R 个线程正好执行完各自的 CPU 计算。这样 CPU 的利用率就达到了100%。” 假设线程A和线程B同时开始IO操作,而且线程C开始CPU计算,当线程C计算结束的时候,线程A和线程B的IO操作还没有结束,这样的话50%的CPU是不是被浪费了?是不是还差一条线程接上CPU呢? 作者回复: 你放长远一点看,第一轮CPU等待,第二轮自动就错开了。
4.I/O密集型中存在的I/O多路复用的问题
对于I/O密集型应用,工程上一般会区分I/O请求响应线程和工作线程的话,而前者的线程池大小——按照Hikari推荐的——比较好数量是: 核数*2+1,因为前者可以进行I/O多路复用,请问老师这个事情是否是这么理解的? 作者回复: 经验值是个很好的开始,是不是合适还得压测
5.RPC调用时,如果他是BIO可以将它视为IO
在RPC调用时,如果他是BIO可以将它视为IO,在进行I/O耗时和CPU耗时,将远程调用部分视为IO耗时 调用三方接口,需要组织参数,调用,响应处理。调用三方接口 如果是rpc,并且是bio,那么rpc完全看成io就行了 王老师,我对问题的理解:没有考虑各自耗时情况。如果核数越多,I/O耗时越大,会造成CPU多核都在I/O中执行,影响了延时和吞吐量。
6.IO可以排队,但是没办法并行
5.线程池相关
使用线程池,一定要有监控类
1.线程池
观点一:一个应用使用一个全局线程池
理由
1.将精力花费在算法的优化上。 2.配置一个全局的线程池 线程池的配置,按照经验配置一个参数,然后随时关注线程池大小对程序的影响即可。具体做法:为程序配置一个全局的线程池,需要异步执行的任务,扔到这个全局线程池处理,线程池大小按照经验设置,每隔一段时间打印一下线程池的利用率。 3.通过缓存、优化业务逻辑 经常看到为每一个异步任务创建一个线程池,导致整个程序的线程池数量过多,这种方式没有必要。多数情况提高吞吐量可以通过使用缓存、优化业务逻辑、提前计算好等方式处理,没有必要太关注线程池的大小分配。 另外经验值不靠谱的原因是一台服务器上跑了很多程序,每个程序有自己的线程池,CPU该如何分配。这个根据实际情况分配。
应用中存在过多的线程池,感觉没用
通过缓存、优化业务逻辑体提高吞吐量
将精力放在算法优化
观点二:一个应用使用多个线程池
1.从JUC提供的不同类型的线程池可以看出,不同的任务类型需要不同类型的线程池。
2.使用一个线程池,线程的名字一致,不利于排查问题。
3.共用一个线程池,如果遇到某些任务处理数量大且时间较长,会影响到后续加到线程池任务队列里面的其他任务,尤其是一些对时效性要求较高的任务。
4.切分线程池的一个使用场景,工程上有些服务需要做资源隔离,来保证对应服务执行线程的稳定性,不会受其他非重要业务影响;这个点来考虑新增线程池还是有必要的。
5.资源隔离很重要
2.线程数量计算案例
1 在现实项目如何计算I/O耗时与CPU耗时呢,比如程序是读取网络数据,然后分析,最后插入数据库。这里网络读取何数据库插入是两次IO操作,计算IO耗时是两次的和。 2. 如果我在一台机器上部署2个服务,那计算线程数是要每个服务各占一半的数量,这种计算方式不正确的。理论值仅仅适用部署一个服务的场景。 3. 如果我用一个8核CPU的机器部署服务,启动8个不同端口的相同服务,和启动一个包含8个线程的服务在处理性能上会有区别。
理论值适用于单台服务器部署单个应用。
3.计算进程中线程数,不考虑其他的进程
没有考虑计算机上还有其他的进程与java共享计算机资源的原因:操作系统为进程提供了抽象,让进程觉得自己在独占计算机资源。这是对问题的一种简化。 并且,阿姆达尔定律 以及 线程数=CPU核数*(1+io与CPU耗时比)都是一个经验公式。在实际的开发中需要实际的测试结果进行调优。
操作系统为进程提供了抽象,让进程觉得自己在独占计算机资源。这是对问题的一种简化。
4.集群中线程数
在一个集群里,线程数如何计算。 例如有三台机器构成一个集群,这三台机器的cpu分别是8核,4核,2核。假设都是是cpu密集型,则每台机器算自己的,发挥出每台机器的硬件能力就可以了
单台服务器性能发挥到极致就可以
6.使用容器部署,线程池大小如何设定
理论加经验加实际场景,比如现在大多数公司的系统是以服务的形式来通过docker部署的,每个docker服务其实对应部署的就一个服务,这样的情况下是可以按照理论为基础,再加上实际情况来设置线程池大小的,当然通过各种监控来调整是最好的,但是实际情况是但服务几十上百,除非是核心功能,否则很难通过监控指标来调整线程池大小。理论加经验起码不会让设置跑偏太多,还有就是服务中的各种线程池统一管理是很有必要的
1.可以按照理论值设置初始大小,然后测试调整
2.服务中各种线程池统一管理
3.docker的隔离性,可以按照只有一个一台机器只有一个应用来计算线程数
这句话也不完全对,在进行容器编排是,可能只给该服务分配2个CPU。还是要具体场景具体分析。 如果使用Runtime.getRuntime().availableProcessors()获取的整个服务器的逻辑处理器个数,而不是容器分配的CPU资源。
8.线程池:线程级别的资源隔离
不同的任务配置不同的线程池,实现任务隔离
不同的任务创建不同的线程池,在分析造成瓶颈的线程任务很有帮助
7.线程池监控类
线程池怎么做监控:自己写个包装类。 https://www.cnblogs.com/clovejava/p/10053916.html 线程池监控类 https://blog.csdn.net/qq_16605855/article/details/83541260
9.不同应用线程池的数量不同
1.apm是每个方法级别的, 是把应用里大部分接口比例取出来然后算平均的比例么 2.设置线程池的应用有很多 , 例如tomcat 、 druid 、redis 等等 , 是每个应用都配置相同的线程池数量么 作者回复: 1.只关注io花了多少时间就可以了,用时间减去它就是cpu时间 2.tomcat设置要比其他的大,因为他是流量入口
tomca是流程入口,线程池的线程数量要大一些
6.并发编程的目的,衡量性能的指标
采用并发编程,最大的目的就是提高硬件CPU和I/O的利用率,从而提升系统的性能。性能指标可以用延迟、并发、QPS等指标来表示。 采用多少个线程来执行,没有太好的公式可以表示,工作中还是会以实际情况分析、测试得出结果
并发编程目的:提高硬件CPU和I/O的利用率,从而提升系统的性能
衡量指标:用延迟、并发、QPS等指标来表示
7.一台服务器部署多个进程,进程间竞争资源
1.42 一台服务器上部署多个进程,进程之间的线程池会竞争资源。 一台机器上部署很多东西,并发大不到哪里 计算一个进程拥有所有cpu io资源情况下的最佳线程数,但一台服务器上肯定有多个进程,比如tomcat,日志收集agent,那么多个进程间的线程池肯定会竞争资源,这又如何考虑呢? 作者回复: 如果一台服务器上部署很多东西,估计并发也大不哪里去。抓住核心矛盾,日志收集怎么会让他占很多资源,分配0.1核就可以了。理论计算仅用来指导而已。
一台服务器部署很多服务,并发也大不到哪里
对于非核心服务分配很少的资源就可以
例如:日志收集分配0.1核就可以了,理论计算仅用来指导而已
11 局部变量线程安全
概述
CPU层面没有方法概念,只有一条条指令
编译程序,负责把高级语言里的方法转换成一条条的指令
要明白局部变量是线程安全,需要明白方法到指令的转换
1.方法如何执行
高级语言 -> 编译指令执行
案例:CPU通过指令执行方法
int a = 7; int[] b = fibonacci(a); int[] c = b; 调用 fibonacci(a) 的时候,CPU 要先找到方法 fibonacci() 的地址,然后跳转到这个地址去执行代码,最后 CPU 执行完方法 fibonacci() 之后,要能够返回。首先找到调用方法的下一条语句的地址:也就是int[] c=b;的地址,再跳转到这个地址去执行。如下图所示 
CPU通过调用栈找到执行的方法
调用栈:CPU堆栈寄存器
该堆栈寄存器和方法调用相关
栈帧:每个方法在调用栈里都有自己的独立空间
有三个方法 A、B、C,他们的调用关系是 A->B->C(A 调用 B,B 调用 C),在运行时,会构建出如下图这样的调用栈。 每个方法在调用栈里都有自己的独立空间,称为栈帧,每个栈帧里都有对应方法需要的参数和返回地址。当调用方法时,会创建新的栈帧,并压入调用栈;当方法返回时,对应的栈帧就会被自动弹出。栈帧和方法是同生共死的。 
栈帧和方法同生共死
特点:先入后出
栈支持方法调用
CPU层面
CPU 里内置了栈寄存器,支持方法调用
高级语言层面
都是靠栈结构解决
java虚拟机
Java 语言虽然是靠虚拟机解释执行的,但方法的调用也是利用栈结构解决的。
2.局部变量存放位置
局部变量的作用域是方法内部,在方法执行完,局部变量就没用了,局部变量应该和方法同生共死。调用栈的栈帧就是和方法同生共死的,所以局部变量放到调用栈里 调用栈的结构如下:  在java中 new 出来的对象是在堆里,局部变量是在栈里。
局部变量存放在调用栈的栈帧:
局部变量作用域:方法内部
方法执行完,局部变量销毁。局部变量和方法同生共死
调用栈的栈帧和方法同生共死的,所以局部变量放到调用栈里
堆栈
局部变量在栈中,new对象在堆中
在java中 new 出来的对象是在堆里,局部变量是在栈里。 
对象在堆里,应用在栈里
为什么区分堆栈
局部变量是和方法同生共死的,一个变量要跨越方法的边界,就必须创建在堆里
调用栈和线程
关系:每个线程都有自己独立的调用栈。多个线程调用相同的方法不会相互干扰

无并发问题
每个线程都有自己的调用栈,局部变量保存在线程各自的调用栈里面,不会共享,所以没有并发问题。
没有共享,就没有并发问题
3.线程封闭
定义:局部变量作用域是方法,线程独有,线程间不共享数据,没有并发问题
这种思想就叫做线程封闭
局部变量随着方法的结束而销毁,性能很好,减少GC时间。在高并发场景下减少服务停顿
采用线程封闭的技术
数据库连接
采用线程封闭技术的案例非常多,例如从数据库连接池里获取的连接 Connection,在 JDBC 规范里并没有要求这个 Connection 必须是线程安全的。数据库连接池通过线程封闭技术,保证一个 Connection 一旦被一个线程获取之后,在这个线程关闭 Connection 之前的这段时间里,不会再分配给其他线程,从而保证了 Connection 不会有并发问题。
4.小结:调用栈是计算机的通用概念
5.思考题:递归调用太深,可能导致栈溢出。思考原因、解决方案
思考题解析
分析
调用栈
栈帧:每调用一个方法就会在栈上创建一个栈帧,方法调用结束后就会弹出该栈帧
栈的大小有限,递归调用次数过多会导致栈溢出
递归
特点:每递归一次,创建一个新的栈帧,保留之前的栈帧,直到遇到结束条件
注意:递归调用要明确结束条件,避免死循环。避免调用栈太深
解决方案
1.使用循环代替递归
缺点:逻辑不够清晰
2.限制递归次数
3.尾递归
尾递归是指在方法返回时只调用自己本身,且不能包含表达式。编译器或解释器会把尾递归做优化,使递归方法不论调用多少次,都只占用一个栈帧,所以不会出现栈溢出。然鹅,Java没有尾递归优化。
java不支持
4.合理的设置栈空间大小;jvm的-Xss参数可以设置栈大小
5.模拟栈结构
6.备忘录算法,自上而下,记住之前算过的值。减少方法的访问次数从而减少运行时间
7.使用HashMap保存结果,减少递归次数
关联知识
栈
JAVA的栈跟cpu的栈没有关系,只是原理相似
CPU的堆栈寄存器和栈帧的关系
1.CPU的堆栈寄存器和栈帧,堆栈寄存器指向栈顶内存地址
2.程序计数器和CPU栈寄存器不是一回事
3.线程切换会导致CPU堆栈寄存器来回刷
jvm中设置栈大小的参数
栈空间是每个线程各自有的一块区域,栈空间太小,会导致OOM
设置栈空间大小,只需要使用 -Xss 参数就可以
JVM线程的栈在64位Linux操作系统上的默认大小是1M
操作系统占用地址空间
32位默认占用4G
子主题
子主题
方法
方法参数
引用数据类型
该参数传递给方法内的局部变量,存在线程安全问题
基本数据类型
数据传递,局部变量的到的是一个独立的数据副本,不存在线程安全问题
静态方法和普通方法区别
同
局部变量线程安全
执行机制和正常方法没区别
异
静态方法没有隐藏的this参数
静态方法在类加载阶段已经创建
ThreadLocal这个类来实现缓存的线程封闭
12:面型对象思想
1.概述
java中并发编程和面向对象融合在一起
1.java是面向对象语言。语言特性:封装、集成、多态
2.管程的思想:封装共享变量,线程互斥,实现线程安全
3.二者可以融合是因为它们都有封装特性。java选择管程是因为管程更加切合面向对象编程
用面向对象写并发程序
三方面
1.封装共享变量
并发程序的核心问题,是解决多线程同时访问共享变量的问题。 现实案例: 球场门票的管理的一个核心问题是:所有观众只能通过规定的入口进入,否则检票就形同虚设。 编程领域: 在编程世界同样面临相同问题,编程领域里面对于共享变量的访问路径就类似于球场的入口,必须严格控制。面向对象思想,可以对共享变量的访问路径可以轻松把控。
1.思想:封装
封装
封装是将属性和实现细节封装在对象内部,外界对象只能通过目标对象提供的公共方法来间接访问这些内部属性。
现实案例
球场门票的管理的一个核心问题是:所有观众只能通过规定的入口进入,否则检票就形同虚设。
编程领域
1.球场里的座位就是对象属性
2.球场入口就是对象的公共方法
3.共享变量作为对象的属性,那对于共享变量的访问路径就是对象的公共方法,所 有入口都要安排检票程序就相当于我们提到的并发访问策略
2.封装共享变量
思路分析:用面向对象思想编写并发编程思路
1.共享变量作为对象属性封装在内部
2.所有公共方法制定并发访问策略。
共享变量
不可变变量
使用final修饰
1.避免并发问题
2.表明你已经考虑并发问题
举例:信用卡账户有卡号、姓名、身份证
可变变量
需要进行封装,制定并发访问策略
举例:信用卡信用额度、已出账单、未出账单
封装共享变量案例
例如:多统计程序都要用到计数器来说,下面的计数器程序共享变量只有一个,就是 value,我们把它作为 Counter 类的属性,并且将两个公共方法 get() 和 addOne() 声明为同步方法,这样 Counter 类就成为一个线程安全的类了。 public class Counter { private long value; 共享变量,且是可变变量。 synchronized long get(){ return value; } synchronized long addOne(){ return ++value; } }
2.识别共享变量间的约束条件
约束条件的重要性:决定了并发访问策略
忽略约束条件案例
例如,库存管理里面有个合理库存的概念,库存量不能太高,也不能太低,它有一个上限和一个下限。 这些约束条件,用下面的程序来模拟。 在类 SafeWM 中,声明了两个成员变量 upper 和 lower,分别代表库存上限和库存下限,这两个变量用了 AtomicLong 这个原子类,原子类是线程安全的,所以这两个成员变量的 set 方法就不需要同步了。 public class SafeWM { // 库存上限 private final AtomicLong upper = new AtomicLong(0); // 库存下限 private final AtomicLong lower = new AtomicLong(0); // 设置库存上限 void setUpper(long v){ upper.set(v); } // 设置库存下限 void setLower(long v){ lower.set(v); } // 省略其他业务代码 } 使用原子类不存在线程安全问题,但是忽略库存上下限,修改上下限没有校验,是一个bug。 如果在设置库存的方法在 setUpper() 和 setLower() 中增加了参数校验,它存在并发问题是存在竞态条件。在并发编程中在代码中出现 if 语句,首先应该考虑可能存在竞态条件
1.原子类线程安全,不需要同步
2.忽略库存上下限,这个约束条件,是一个bug
3.如果加上修改参数校验,则存在竞态条件。
在并发编程中,有if需要考虑竞态条件
竞态条件分析
案例与案例分析
假设库存的下限和上限分别是 (2,10),线程 A 调用 setUpper(5) 将上限设置为 5,线程 B 调用 setLower(7) 将下限设置为 7。 竞态条件分析: 1.线程 A 能够通过参数校验,这个时候,下限还没有被线程 B 设置,还是 2,而 5>2符合库存的上下限的范围; 2.线程 B 也能够通过参数校验,这个时候,上限还没有被线程 A 设置,还是 10,而 7<10不符合库存 的上下限范围。 3.当线程 A 和线程 B 都通过参数校验后,就把库存的下限和上限设置成 (7, 5) 了,显然此时的结果是不符合库存下限要小于库存上限这个约束条件的。 public class SafeWM { // 库存上限 private final AtomicLong upper = new AtomicLong(0); // 库存下限 private final AtomicLong lower = new AtomicLong(0); // 设置库存上限 void setUpper(long v){ // 检查参数合法性 if (v < lower.get()) { 存在竞态条件 throw new IllegalArgumentException(); } upper.set(v); } // 设置库存下限 void setLower(long v){ // 检查参数合法性 if (v > upper.get()) { throw new IllegalArgumentException(); } lower.set(v); } // 省略其他业务代码 }
1.共享变量间的约束条件反应在代码中,基本上都会有if语句
2.并发访问策略使用原子类,保证线程安全,但无法保证约束条件在并发场景下的正确性
并发访问策略是利用原子类,可以保证共享变量的线程安全性。但是引入库存的约束条件后(反映在代码里,基本上都会有 if 语句,可能存在竞态条件),原子类无法保证这个约束条件在并发场景下的正确性。
3.制定并发访问策略
1.避免共享
利于线程本地存储以及为每个任务分配独立的线程。
2.不变模式
这个在 Java 领域应用的很少,但在其他领域却有着广泛的应用,例如 Actor 模式、CSP 模式以及函数式编程的基础都是不变模式。
3.管程以及其他公布工具
Java 领域万能的解决方案是管程,但是对于很多特定场景,使用 Java 并发包提供的读写锁、并发容器等同步工具会更好。
2.编写健壮并发程序
三个宏观原则
1.优先使用成熟的工具类
Java SDK 并发包里提供了丰富的工具类
1.优先使用成熟的工具类:Java SDK 并发包里提供了丰富的工具类,基本上能满足你日常的需要,建议你熟悉它们,用好它们,而不是自己再“发明轮子”。
2.迫不得已时才使用低级的同步原语
低级的同步原语主要指的是 synchronized、Lock、Semaphore 等
2.迫不得已时才使用低级的同步原语:低级的同步原语主要指的是 synchronized、Lock、Semaphore 等,这些虽然感觉简单,但实际上并没那么简单,一定要小心使用。
3.避免过早优化
安全第一,瓶颈无法预估,优化做了也白做
3.避免过早优化:安全第一,并发程序首先要保证安全,出现性能瓶颈后再优化。在设计期和开发期,很多人经常会情不自禁地预估性能的瓶颈,并对此实施优化,但残酷的现实却是:性能瓶颈不是你想预估就能预估的。
小结:面向对象思想编写并发程序,关键点是利用面向对象里的封装特性
利用面向对象思想编写并发程序,一个关键点就是利用面向对象里的封装特性,这里我只做了简单介绍,详细的你可以借助相关资料定向学习。 对共享变量进行封装,要避免“逸出”,所谓“逸出”简单讲就是共享变量逃逸到对象的外面,比如在《02 | Java 内存模型:看 Java 如何解决可见性和有序性问题》那一篇我们已经讲过构造函数里的 this“逸出”。这些都是必须要避免的。
避免逸出
思考题:约束条件
类 SafeWM 不满足库存下限要小于库存上限这个约束条件,进行修改,使它能够在并发条件下满足库存下限要小于库存上限这个约束条件。 public class SafeWM { // 库存上限 private final AtomicLong upper = new AtomicLong(0); // 库存下限 private final AtomicLong lower = new AtomicLong(0); // 设置库存上限 void setUpper(long v){ 不存在线程安全性,但是上下限没有校验。如果校验,则引入if判断,存在竞态条件。 upper.set(v); } // 设置库存下限 void setLower(long v){ lower.set(v); } // 省略其他业务代码 }
思考题分析
1.共享变量封装到对象中
案例1:多个共享变量封装到对象中
public class Boundary { private final lower; private final upper; public Boundary(long lower, long upper) { if(lower >= upper) { // throw exception } this.lower = lower; this.upper = upper; } } public class SafeWM { // 库存上限 private final AtomicLong upper = new AtomicLong(0); // 库存下限 private final AtomicLong lower = new AtomicLong(0); // 设置库存上限 void setUpper(long v){ upper.set(v); } // 设置库存下限 void setLower(long v){ lower.set(v); } // 省略其他业务代码 }
CAS一次只更新一个共享变量
案例2:CAS更新对象,并使用局部变量保证线程安全
final AtomicReference<Inventory> inventory = new AtomicReference<>(); //使用final保证对象安全发布,不需要使用volatile static class Inventory { private volatile long upper = 0; private volatile long lower = 0; } void setUpper(long v) { long low; Inventory oldObj; Inventory newObj; do { oldObj = inventory.get(); if (v >= (low = oldObj.lower)) { throw new IllegalArgumentException(); } newObj = new Inventory(); newObj.lower = low; newObj.upper = v; } while (inventory.compareAndSet(oldObj, newObj)); } void setLower(long v) { long upp; Inventory oldObj; Inventory newObj; do { oldObj = inventory.get(); if (v <= (upp = oldObj.upper)) { throw new IllegalArgumentException(); } newObj = new Inventory(); newObj.lower = v; newObj.upper = upp; } while (inventory.compareAndSet(oldObj, newObj)); }
使用CAS跟新共享变量的范式
关联知识
1.锁
1.粗粒度锁造成性能问题,使用读写锁,改善性能
对于两个互相比较的变量来说,赋值的时候只能加锁来控制。但是这也会带来性能问题,可以采用读锁和写锁来优化,申请写锁了就互斥,读锁可以并发访问,这样性能相对粗粒度的锁来说会高点。
2.多实例部署,共享数据的安全性,使用分布式锁
管程是进程级别的锁,如果是分布式计算只能靠redis,db,zk这些来搞分布式的锁。不共享是最好的解决方案。
3.使用Lock锁,等待-通知机制
使用 Condition public class SafeWM { // 库存上限 private final AtomicLong upper = new AtomicLong(10); // 库存下限 private final AtomicLong lower = new AtomicLong(2); private ReentrantLock lock = new ReentrantLock(); private Condition c1 = lock.newCondition(); private Condition c2 = lock.newCondition(); // 设置库存上限 void setUpper(long v) { try { lock.lock(); // 检查参数合法性 while (v < lower.get()) { c1.await(); } upper.set(v); c2.signal(); } catch (Exception e) { e.printStackTrace(); } finally { lock.unlock(); } } // 设置库存下限 void setLower(long v) { try { lock.lock(); // 检查参数合法性 while (v > upper.get()) { c2.await(); } lower.set(v); c1.signal(); } catch (Exception e) { e.printStackTrace(); } finally { lock.unlock(); } } }
4.三种锁方案讨论
synchronized
解决并发问题的万金油,但太重了,高并发会造成性能问题
传入同一个变量,作为锁
工程上不可行
volatile
只能保证一个变量在不同线程间的可见性
5.静态锁是一个全局锁,影响性能
2.final
final保证不会逃逸的前提
构造函数中没有this
final保证不会逃逸,这句话理解有误。 fianl的禁止重排序前提是构造函数里面没有this逃逸,它只保证final变量不会重排序到构造函数之外,并不保证逃逸。 保证final变量不会重排序到构造函数之外。这句话的理解: 类A中的属性是final类型,在A通过构造函数初始化完成前,final属性对外不可见。 如果构造函数中有发布this,则final类型属性初始化的中间状态,对外可见。既发生逃逸现象。
3.原子类
1.使用原子类更新共享变量,如果有约束条件,则依然存在竞态条件
案例
要保证变量间的约束条件,就必须保证判断和赋值是一个原子操作,可以通过给 upper 和 lower 同时加锁。但使用原子类无法解决竞态条件。 // 设置库存上限 void setUpper(long v) { // 检查参数合法性 upper.getAndUpdate(u -> { 存在竞态条件 if (v < lower.get()) { throw new IllegalArgumentException(); } else { return v; } }); } // 设置库存下限 void setLower(long v) { // 检查参数合法性 lower.getAndUpdate(u -> { if (v > upper.get()) { throw new IllegalArgumentException(); } else { return v; } }); } 老师这样理解对吗 作者回复: 我感觉不可以,还是有竞态条件,你可以在return前面增加sleep看看
2.CAS只能更新一个共享变量
多个共享变量
1.将多个共享变量拼成一个字符串,进行更新
2.将多个共享变量封装到对象中。
4.安全发布的常用模式
可变对象必须通过安全的方式来发布
意味着在发布和使用该对象的线程时都必须使用同步
可变对象必须通过安全的方式来发布,通常意味着在发布和使用该对象的线程时都必须使用同步。现在,我们将重点介绍如何确保使用对象的线程能够看到该对象处于已发布的状态,并稍后介绍如何在对象发布后对其可见性进行修改。
确保使用对象的线程看到该对象处于已发布的状态
安全地发布一个对象,对象的应用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:
1.在静态初始化函数中初始化一个对象引用
2.将对象保存到volatile域中或者AtomicReferance对象中
3.将对象的引用保存到某个正确构造对象的final类型域中
4.将对象的引用保存到一个由锁保护的域中。
如何在对象发布后对其可见性进行修改。
5.不同并发机制,尽量不要混用
例如:volatile和synchronized以及原子变量
6.SDK存在的意义:框架没有解决并发问题,所有才有了JUC中的并发类
框架没有帮我们解决并发问题,例如SpringMVC。并发问题需要我们自己解决。需要熟悉并发包中的提供的原子类。
7.解决并发问题的三个方向
加锁
封装
两个属性额外定义一个类,属性final。当修改的时候只能额外创建新的类,修改引用。
原子引用
并发替换类的引用问题,可以通过原子引用,返回boolean判断。具体不成功之后的策略根据实际情况来。 do{ }while(reference.compareAndSet(oldObject,newObject));