专栏简介: JavaEE从入门到进阶
题目来源: leetcode,牛客,剑指offer.
创作目标: 记录学习JavaEE学习历程
希望在提升自己的同时,帮助他人,,与大家一起共同进步,互相成长.
学历代表过去,能力代表现在,学习能力代表未来!
1. 线程状态
1.1 观察线程的所有状态
1.2 线程的状态和状态转移的意义
2.线程安全
2.1 线程安全的概念:
2.2 线程安全问题的原因
2.3 从原子性角度解决线程安全问题
synchronized 关键字使用方法:
线程的状态 Thread.State 是一个枚举类型. 可通过遍历查看其所有类型.
public static void main(String[] args) {
for (Thread.State state : Thread.State.values()){
System.out.println(state);
}
}
- 1. NEW: 创建了 Thread 对象 , 但还没有调用 start (内核中还没有创建对应的PCB)
- 2. TERMINATED: 表示内核中的 PCB 已执行完毕 , 但Thread对象还在.
- 3. RUNNABLE: 可运行的. 分为两种情况 a).正在CPU上执行的 b).在就绪队列中 , 随时可以去CPU上执行. 一般不做区分.
- 4. WAITING: 表示线程 PCB 正在阻塞队列中
- 5. TIMED_WAITING: 表示线程 PCB 正在阻塞队列中
- 6. BLOCKED: 表示线程 PCB 正在阻塞队列中
通过下面代码来演示 , 相比于单线程 , 多线程效率的提升.
假设有两个变量 a 和变量 b , 现需要将两个变量各自自增100亿次.(典型的 CPU 密集型场景)
Tips: 编写多线程代码时 , 不能调用完 start 方法后就立即结束计时 , 还需调用 jion 方法等待 t1 和 t2 两个线程结束. 这就好比 main线程是裁判员 , t1 和 t2 是准备赛跑的运动员 , 裁判一声令下还没等运动员反应过来就立即结束计时 , 这显然是不合常理的.裁判需等待运动员跑过终点线再结束计时.
public static void main(String[] args) throws InterruptedException {
// serial();
concurrency();
}
/**
* 多线程执行
* @throws InterruptedException
*/
public static void concurrency() throws InterruptedException {
Thread t1 = new Thread(()->{
long a = 0;
for (long i = 0; i < 10000_0000_00L; i++) {
a++;
}
});
Thread t2 = new Thread(()->{
long b = 0;
for (long i = 0; i < 10000_0000_00L; i++) {
b++;
}
});
long startTime = System.currentTimeMillis();
t1.start();
t2.start();
t1.join();
t2.join();
long endTime = System.currentTimeMillis();
System.out.println("执行时间"+ (endTime-startTime)+"ms");
}
/**
* 单线程执行
*/
public static void serial(){
long a = 0;
long b = 0;
long startTime = System.currentTimeMillis();
for (long i = 0; i < 10000_0000_00L; i++) {
a++;
}
for (long i = 0; i < 10000_0000_00L; i++) {
b++;
}
long endTime = System.currentTimeMillis();
System.out.println("执行时间: "+(endTime-startTime)+"ms");
}
观察执行结果我们可以发现 , 相比于单线程执行 , 多线程执行可以节省大量时间 , 但并非我们认为的节省一半时间 , 这是因为多线程在调度时还会有额外的开销 , 而且不能保证多线程一定是在两个CPU上执行.
由此我们可以得出结论: 不是说使用多线程就一定能提高效率!!还需考虑以下两点:
- CPU是否是多核 (现在CPU基本都是多核)
- 当前核心是否空闲 (如果CPU的所有核心都已满载 , 此时启用再多的线程也无济于事)
线程不安全的主要原因是多线程的抢占式执行带来的随机性 , 原本在单线程中 , 代码按照固定的顺序执行 , 那么程序的执行结果就是固定的 , 如果有了多线程 , 代码执行顺序的可能性就从一种情况变成无数种情况!!只要有一种情况 , 程序执行结果不正确 , 就会视为线程不安全.
如果多线程环境下代码的运行结果符合我们的预期 , 即是在单线程环境下预期的结果 , 则说这个线程是线程安全的.
线程不安全示例:
创建两个线程分别对 count 自增5w次 , 按照预期执行结果应是的 count = 10w次.
class Counter{
public int count;
public void add(){
count++;
}
}
public class ThreadDemo2 {
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("count = "+counter.count);
}
}
多次运行观察结果与我们预期相差较大 , 明显出现了bug.
那么程序为什么会出现上述的bug呢?
count++ 操作本质上要分为三步:
- 1. 先把内存中的值 , 读取到CPU的寄存器中. load
- 2. 把CPU寄存器里的数值进行+1运算. add
- 3. 把得到的结果都写到内存中. save
如果两个线程并发执行count++ , 此时相当于两组 load add save 进行执行 , 此时不同的线程调度顺序就可能产生结果上的差异. 如下图所示 , 线程的调度顺序有无数种可能 , 但只有第一种执行顺序是安全的.
正确执行顺序: t1 线程先进行 load 操作 , 将count=0传入寄存器中 , 再进行 add 操作将寄存器中的值+1 , 最后执行 save 操作将寄存器中的值保存到内存中. t2 线程操作顺序与 t1 线程一致 , 最终计算结果为 2.
错误执行顺序: t1 和 t2 先后执行 load 操作 , 此时两个寄存器中 count=0.接着 t2 执行 add 操作将寄存器中的值+1 , 最后执行 save 操作 , 将count=1保存到内存中. 然后 t1 执行 add 和 save 操作 , 最后还是将count=1保存到内存中 , 此时我们发现经历了两次自增 , 结果还是1.造成该结果的原因是 t1 读取了 t2 还未提交的脏数据.(脏读)
1.[根本原因] 抢占式执行 , 随机调度.
多线程本身的特点 , 无能为力.
2.[代码结构] 修改共享数据
在上述不安全的多线程代码中 , 涉及到多个线程对 counter.count 变量进行修改 , 此时这个counter.count 就是一个多线程都能访问到的共享数据.
Tips: counter.count 这个变量就在堆上 , 因此可以被多个线程访问.
3.原子性
一条Java语句不一定是原子的 , 也不一定只是一条指令.
比如 我们刚才看到的 count++ 其实就是三步操作:
- 从存储把数据读到CPU寄存器
- 更新数据
- 把数据写回到CPU
如果一个线程正在进行操作 , 中途其他线程突然插进来 , 如果这个操作被打断了 , 结果很可能是错误的.这个问题的本质还是多线程的抢占式执行 , 如果线程不是"抢占"的 , 即使不是原子的也没有问题.因此解决这个线程安全问题 , 最主要的手段就是从原子性入手 , 把这个非原子的操作变成原子的 , 常见办法就是加锁.
4.内存可见性
可见性指 , 一个线程对共享变量值的修改 , 能够及时的被其他线程看到.后续会在volatile关键字专栏做更详细的讲解.
5.指令重排序(本质上是编译器优化出bug)
一段代码的编写是这样的:
1.去前台去U盘
2.去学习10min
3.去前台取快递
在单线程中执行时 , JVM 和 CPU 指令集 , 会对其进行优化 , 按照1->3->2 的方式执行 , 这样可以少跑一次柜台提高代码执行的效率 , 这种叫做指令重排序.编译器指令重排序的前提是"保持代码逻辑不会发生变化" , 在单线程的环境下代码执行逻辑可以很好的预测 , 但是在多线程的环境下 , 代码复杂度更高 , 编译器很难在编译时期就对代码的执行结果进行预测 , 因此激进的重排序可能导致优化后的逻辑与之前不等价.
通过加锁操作把不是原子的操作变为"原子"的.因此我们可以使用 synchronized 关键字对线程加锁 , 如果两个线程同时尝试加锁 , 此时只有一个线程能成功 , 另一个线程只能阻塞等待(BLOCKED) , 一直阻塞到刚才的线程释放锁 , 另一个线程才能加锁成功.
lock 的阻塞就把刚才的 t2 的 load 推迟到 t1 的 save 之后 , 从而避免了脏读.加锁虽说是保证原子性 , 其实并不是让这三个操作一次性完成 , 也不是这三步操作过程中不执行调度 , 而是让其他也想执行的线程阻塞等待.(加锁的本质就是把并发变成串行)
打个比方就是 , 一个女生如果没有男朋友就是没有加锁的状态 , 其他男生都可以去追求她 , 一但有了男朋友 , 这个女生就加锁了 , 其他男生想追求只能等 , 这个女生和他男朋友分手相当于释放锁 , 释放锁之后其他男生才能去追求.
修改部分代码:
class Counter{
public int count;
public synchronized void add(){
count++;
}
}
运行结果符合预期 , synchronized 关键字下篇文章会专门讲解 , 这里不展开赘述.