Java并发 -- 线程数量
多线程的目的
使用多线程的目的是为了_提高程序性能_
度量程序性能的核心指标:_延迟 + 吞吐量_
延迟:发出请求到收到响应的时间,延迟越短,意味着程序执行得越快,性能越好
吞吐量:在单位时间内能处理请求的数量,吞吐量越大,意味着程序能处理的请求越多,性能越好
同等条件下,延迟越短,吞吐量越大,但两者隶属于不同的维度(一个时间维度,一个空间维度),并不能互相转换
提升程序性能:_降低延迟,提高吞吐量_
多线程的应用场景
要达到降低延迟,提高吞吐量的目的,有两个方向:一个是优化算法,一个是_将硬件的性能发挥到极致_
前者属于算法范畴,后者与并发编程息息相关
在并发编程领域,_提高性能本质上就是要提高硬件的利用率_,主要是提升IO利用率和CPU利用率
操作系统解决硬件利用率问题的对象往往是单一的硬件设备,而并发编程要解决CPU和IO设备综合利用率的问题
综合利用率假设程序按照CPU计算和IO操作交叉执行的方式运行,而且CPU计算和IO操作的耗时是1:1
单线程
单线程时,执行CPU计算的时候,IO设备空闲,执行IO操作时,CPU空闲,所以CPU利用率和IO设备的利用率都是50%
两线程
...
Java并发 -- 线程生命周期
通用的线程生命周期
初始状态
线程已经被创建,但还不允许分配CPU执行
该状态属于编程语言所特有,仅仅在编程语言层面被创建,在操作系统层面,真正的线程还没有创建
可运行状态
线程可以分配CPU执行,该状态下真正的操作系统线程已经被创建
运行状态
当有空闲的CPU时,操作系统会将其分配给处于可运行状态的线程,被分配到CPU的线程的状态就转换为运行状态
休眠状态
处于运行状态的线程如果调用一个阻塞的API或者等待某个事件,那么线程状态就会切换为休眠状态
切换为休眠状态的同时会释放CPU使用权,_处于休眠状态的线程永远没有机会获得CPU使用权_
当等待的事件出现后,线程就会从休眠状态切换到可运行状态
终止状态
线程执行完或者出现异常就会进入终止状态,处于终止状态的线程不会切换到其它状态
进入终止状态意味着线程生命周期的结束
简化合并
通用的线程生命周期里的5种状态在不同的编程语言会有简化合并
Java把可运行状态和运行状态合并了
这两个状态对操作系统调度层是有价值的,但JVM把线程调度交给了操作系统处理,JVM并不关心这两个状态
JVM同时也细化了休眠状态
Java线程的生命周期 ...
Java核心 -- 字符串
String
String是Java语言非常基础和重要的类,提供了构造和管理字符串的各种基本逻辑,是典型的Immutable类
String是Immutable类的典型实现,原生的保证了基础线程安全,因为无法对它内部数据进行任何修改
被声明为final class,由于String的不可变性,类似拼接、裁剪字符串等动作,都会产生新的String对象
由于字符串操作的普遍性,所以相关操作的效率往往对应用性能有明显影响
StringBuffer
StringBuffer是为了解决拼接产生太多中间对象的问题而提供的一个类
StringBuffer本质是一个线程安全的可修改字符串序列,保证了线程安全,但也带来了额外的性能开销
StringBuffer的线程安全是通过把各种修改数据的方法都加上synchronized关键字实现的
这种方式非常适合常见的线程安全类的实现,不必纠结于synchronized的性能
过早的优化是万恶之源,可靠性、正确性和代码可读性才是大多数应用开发的首要考虑因素
StringBuilder
StringBuilder在能力上和StringBuffer没有本质区别,但去掉了线程 ...
Java并发 -- 管程
概述
Java语言在1.5之前,唯一提供的并发原语是管程
在Java 1.5提供的JUC包中,也是以管程技术为基础的
管程是一把解决并发问题的万能钥匙
管程
在Java 1.5之前,仅仅提供synchronized关键字和wait/notify/notifyAll方法
Java采用的是管程技术,synchronized关键字以及wait/notify/notifyAll方法都是管程的组成部分
管程和信号量是等价的(即用管程能实现信号量,用信号量也能实现管程),但管程更容易使用,所以Java选择了管程
Monitor,在Java领域会翻译成监视器,在操作系统领域会翻译成管程
管程:_管理共享变量以及对共享变量的操作过程,让它们支持并发_
对应Java领域:管理类的成员变量和成员方法,让这个类是线程安全的
MESA模型
在管程的发展史上,先后出现了三种不同的管程模型,分别是:Hasen模型、Hoare模型和MESA模型
现在广泛应用的是MESA模型,Java管程的实现也参考了MESA模型
管程可以解决并发领域的两大核心问题:_互斥+同步_
互斥:在同一时刻只允许 ...
Java核心 -- 引用
强引用、软引用、弱引用、幻象引用主要差别:对象不同的可达性(reachable)和对垃圾收集的影响
强引用 - Strong
最常见的普通对象引用,只要还有强引用指向一个对象,就表明该对象还存活,垃圾收集器不会处理这种对象
一个普通的对象,如果没有其他的引用关系,一旦超过了引用的作用域或者显式地将强引用赋值为null,就有可能被收集
软引用 - Soft
软引用是一种相对于强引用弱化一些的引用,可以让对象豁免一些垃圾收集(内存不足)
只有当JVM认为内存不足时,才会去试图回收软引用指向的对象
JVM会确保在抛出OutOfMemoryError之前,清理软引用指向的对象,软引用通常用来实现内存敏感的缓存
弱引用 - Weak
弱引用不能使对象豁免垃圾收集,仅仅只是提供一种访问在弱引用状态下对象的途径
弱引用可以用来构建一种没有特定约束的关系,例如维护一种非强制性的映射关系
如果试图获取时对象还在,就使用它,否则重新实例化
弱引用也是很多缓存实现的选择
虚引用 - Phantom
不能通过虚引用访问到对象,虚引用仅仅只是提供一种机制:_确保对象被finalize以后执行某些事情_
可以利用虚引用监控 ...
Java并发 -- 安全性、活跃性、性能
安全性问题
线程安全的本质是正确性,而正确性的含义是程序按照预期执行
理论上线程安全的程序,应该要避免出现可见性问题(CPU缓存)、原子性问题(线程切换)和有序性问题(编译优化)
需要分析是否存在线程安全问题的场景:_存在共享数据且数据会发生变化,即有多个线程会同时读写同一个数据_
针对该理论的解决方案:不共享数据,采用线程本地存储(Thread Local Storage,TLS);不变模式
数据竞争数据竞争(Data Race):多个线程同时访问同一数据,并且至少有一个线程会写这个数据
add12345678910private static final int MAX_COUNT = 1_000_000;private long count = 0;// 非线程安全public void add() { int index = 0; while (++index < MAX_COUNT) { count += 1; }}
add + synchronized123456789101112131415161718priv ...
Java并发 -- 等待-通知机制
循环等待
在《Java并发 – 死锁》中,通过破坏占用且等待条件来规避死锁,核心代码如下
while (!allocator.apply(this, target)) {}
如果apply()操作的时间非常短,并且并发不大,该方案还能应付
一旦apply()操作比较耗时,或者并发比较大,该方案就不适用了
因为这可能需要循环上万次才能获得锁,非常_消耗CPU_
最好的方案:_等待-通知_
当线程要求的条件不满足,则线程阻塞自己,进入等待状态
当线程要求的条件满足后,通知等待的线程,重新开始执行
线程阻塞能够避免因循环等待而消耗CPU的问题
Java语言原生支持等待-通知机制
线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态
当要求的条件满足时,通知等待的线程,重新获取互斥锁
等待-通知机制Java语言原生支持的等待-通知机制:**synchronized + wait + notify/notifyAll**
wait
每个互斥锁有两个独立的等待队列,如上图所示,等待队列L和等待队列R
左边有一个等待队列(等待队列L),_在同一时刻,只允许一个 ...
Java并发 -- 死锁
Account.class
在《Java并发 – 互斥锁》中,使用了Account.class作为互斥锁来解决银行业务的转账问题
虽然不存在并发问题,但所有账户的转账操作都是串行的,性能太差
例如账户A给账户B转账,账户C给账户D转账,在现实世界中是可以并行的,但该方案中只能串行
账户和账本
每个账户都对应一个账本,账本统一存放在文件架上
银行柜员进行转账操作时,需要到文件架上取出转出账本和转入账本,然后转账操作,会遇到三种情况
如果文件架上有转出账本和转入账本,都同时拿走
如果文件架上只有转出账本或只有转入账本,那需要等待那个缺失的账本
如果文件架上没有转出账本和转入账本,那需要等待两个账本
两把锁123456789101112131415161718public class Account { // 账户余额 private int balance; // 转账 public void transfer(Account target, int amt) { // 锁定转出账户 synchronized (this) & ...
Java并发 -- 互斥锁
解决什么问题互斥锁解决了并发程序中的原子性问题
禁止CPU中断
原子性:一个或多个操作在CPU执行的过程中不被中断的特性
原子性问题点源头是线程切换,而操作系统依赖CPU中断来实现线程切换的
单核时代,禁止CPU中断就能禁止线程切换
同一时刻,只有一个线程执行,禁止CPU中断,意味着操作系统不会重新调度线程,也就禁止了线程切换
获得CPU使用权的线程可以不间断地执行
多核时代
同一时刻,有可能有两个线程同时在执行,一个线程执行在CPU1上,一个线程执行在CPU2上
此时禁止CPU中断,只能保证CPU上的线程不间断执行,但并不能保证同一时刻只有一个线程执行
互斥:同一时刻只有一个线程执行
如果能保证对共享变量的修改是互斥的,无论是单核CPU还是多核CPU,都能保证原子性
简易锁模型
临界区:一段需要互斥执行的代码
线程在进入临界区之前,首先尝试加锁lock()
如果成功,则进入临界区,此时该线程只有锁
如果不成功就等待,直到持有锁的线程解锁
持有锁的线程执行完临界区的代码后,执行解锁unlock()
锁和资源
synchronized12345678910111213141516 ...
Java并发 -- Java内存模型
解决什么问题Java内存模型解决了并发程序中的可见性问题和有序性问题
Java内存模型按需禁用
CPU缓存会导致可见性问题,编译优化会导致有序性问题
解决可见性和有序性最直接的办法:_禁用CPU缓存和编译优化_
问题虽然解决了,但程序性能会大大下降
合理的方案:_按需禁用CPU缓存和编译优化_
为了解决可见性和有序性的问题,只需要给程序员提供按需禁用CPU缓存和编译优化的方案即可
程序员视角
Java内存模型规范了_JVM如何提供按需禁用CPU缓存和编译优化的方法_
具体方法包括:volatile、synchronized和final关键字,以及六个Happens-Before规则
volatile
volatile关键字不是Java语言的特产,在古老的C语言也有,最原始的意义就是_禁用CPU缓存_
volatile int x = 0:告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取或者写入
Java代码1234567891011121314151617public class VolatileExample { private int x = 0; ...