1. 线程的所有状态
进程状态:
就绪:正在 cpu 上执行,或者随时可以去 cpu 上执行
阻塞:暂时不能参与 cpu 执行
Java 的线程,对于状态做了更详细的区分,不仅仅是就绪和阻塞了,六种:
- NEW:当前 Thread 对象虽然有了,但是内核的线程还没有(还没调用 start)
- TERMINATED:当前 Thread 对象虽然还在,但是内核的线程已经销毁了(线程已经结束了)
- RUNNABLE:就绪状态,正在 cpu 上运行 或 随时可以去 cpu 上运行
- BLOCKED:因为 锁竞争 引起的阻塞
- TIMED_WAITNG:有超时时间的等待,比如 sleep 或者 join 带参数版本
- WAITING:没有超时时间的等待 join /wait
上述线程状态都可以通过 jconsole 来观察
2. 线程安全
2.1 线程不安全代码:
public class Demo {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
我们预期结果为 100000,运行结果为:
实际运行结果与预期不符,这就是 bug
当把代码改为:
上面写法中 t1 先执行,t1 执行完,t2 再执行,t1 和 t2 是串行执行的,这样就没问题
而一开始的代码中 t1 和 t2 是并发执行的,是有 bug 的
像这样因为多个线程并发执行引起的 bug 称为“线程安全问题”或者叫做“线程不安全”
2.2 上例线程不安全的原因:
上述代码中的 count++ 操作,在 cpu 的视角来看,是 3 个指令
1) 把内存中的数据读取到 cpu 寄存器里 (load)
2) 把 cpu 寄存器里的数据 +1 (add)
3) 把寄存器的值写回内存 (save)
tip:由于不同架构的 cpu 有不同的指令集,不同的指令集里有不同的指令,针对这三个操作,不同 cpu 里的对应指令名称肯定是不同的!
cpu 在调度执行线程的时候,不一定什么时候就会把线程给切换走(抢占式执行,随机调度)
指令是 cpu 执行的最基本单位,要调度至少把当前指令执行完,不会执行一半调度走
但是由于这里 count++ 是三个指令,可能会出现 cpu 执行了其中的 1 个或 2 个或 3 个指令调度走的情况(都有可能,无法预测)
基于上面的情况,两个线程同时对 count 进行 ++ 就容易出现 bug
画图示例:
但上述的执行顺序,只是一种可能的调度顺序,由于调度过程是“随机”的,因此就会产生很多其他的执行顺序(下面就是得到不正确结果的调度,略写...)
tip:得到值 <50000 的特殊情况
上面这样,在 t1++ 一次的过程中,t2++ 两次,这样的结果一共是 ++3 次,实际上只得到了 1
这样 t1 和 t2 相互踩对方的结果就会出现 <50000 的情况(需要两个线程实际有效 加 的次数都得 < 2.5w)
2.3 线程不安全的原因
1) 线程在操作系统中是 随机调度,抢占式执行(根本原因)
2) 多个线程同时修改同一个变量
3) 修改操作不是“原子”的
4) 内存可见性问题
5) 指令重排序
3. 解决线程不安全问题(synchronized 关键字 -- 监视器锁 monitor lock)
最主要的方法就是把“非原子”的修改成“原子”的
3.1 synchronized 使用
synchronized ( ),是关键字,不是函数,( ) 中的并非“参数”
需要指定一个“锁对象”,通过锁对象来进行后续的判定,这里的 ( ) 可以指定任何的对象
3.1.1 针对上面例子加锁:
{ } 中的代码就是需要加锁的代码,只要是合法的 Java 代码,都可以放入
执行过程分析:
由于 t1 和 t2 都是针对 locker 对象加锁,t1 先加锁成功了,所以 t1 继续执行 { } 中的代码,t2 后加锁,发现 locker 对象已经被其他线程加锁了,所以 t2 只能阻塞等待
又因为 t1 的 unlock 操作一定是在 save 之后,确保了 t2 执行 load 的时候,t1 已经 save ,这样两者进行 ++ 操作,就不会因为穿插执行而导致相互覆盖对方结果了
本质上是把随机的并发执行过程强制变成了串行
tip:
1. 锁对象,最重要的是看多个线程是否是同一个锁对象
针对同一个对象加锁,就会出现“阻塞”(锁竞争/锁冲突)
针对不同对象加锁,不会出现“阻塞”,两个线程仍然是随机调度的并发执行
2. 锁对象不能用 int,double 这种内置类型,必须是 Object 及其子类
3. 加锁代码是比 join 串行效率高很多的,加锁只是将线程中的一小部分逻辑变为“串行执行”,剩下的其他部分仍然可以并发执行
3.1.2 三个线程对同一个对象加锁
假设有 1,2,3 线程
1 先拿到锁,2 和 3 阻塞等待,当 1 释放锁后, 2 和 3 谁先拿到锁是不一定的,是随机的,即使在代码中是 2 先加锁,3 后加锁,也不一定谁先拿到
3.2 synchronized 特性
3.2.1 底层原理
synchronized 是 JVM 提供的功能,synchronized 底层实现就是在 JVM 中通过 C++ 代码实现的,也是依靠 操作系统 提供的 api 实现的加锁,操作系统的 api 则是来自于 cpu 上支持的特殊指令来实现的
因此,加锁操作并不是 Java 独有的,其他语言也有加锁操作
系统原生的加锁 api 其实是两个函数:lock()、unlock()
不仅仅原生 api 是这样,很多编程语言的加锁操作也是类似的封装方法,如 C++/Python 加锁是一个函数,解锁是一个函数,像 Java 这样通过 synchronized 关键字来同时完成加锁解锁是比较少见的
系统原生的这种做法一个最大的问题就是:unlock 可能会执行不到
3.2.2 加锁其他写法
一个 Java 进程中,一个类的类对象是只有唯一一个的,类对象也是对象,所以也能写到 synchronized( ) 里面,写类对象和写其他对象没有任何本质的区别
synchronized 修饰一个普通的方法
1) 相当于针对 this 加锁
2) synchronized 修饰一个静态方法
相当于针对 对应的类对象 加锁
static 方法没有 this ,其也叫做类方法,和具体的实例无关,只和类相关,而 this 是指向实例的
这样的写法就是在给类对象加锁
3.2.3 线程问题之——死锁
分析:
1) add 方法的 synchronized 想要拿到锁,就需要 for 循环中的 synchronized 释放锁
2) for 循环的 synchronized 想要释放锁就需要执行到 }
3) 要想执行到 } 就需要执行完这里的 add
4) 但是 add 正在阻塞中
出现死锁的三种场景:
1) 一个线程针对一把锁,连续加锁两次
但是当我们运行时,发现程序正常运行了,结果并没有问题,没有发生死锁,这是因为 Java 的 synchronized 为了减少我们写出死锁的概率,引入了 “可重入锁” 的特殊机制,解决了上述问题
2) 两个线程两把锁
线程1、线程2、锁A、锁B
线程 1 先对 A 加锁,线程 2 对 B 加锁;线程 1 不是放锁 A 的前提下,再对 B 加锁,同时线程 2 在不释放锁 B 的前提下,再对 A 加锁
public class Demo17 {
private static Object locker1 = new Object();
private static Object locker2 = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (locker1) {
System.out.println("t1 加锁 locker1 完成");
//这里的 sleep 是为了确保 t1 和 t2 都先分别拿到 locker1 和 locker2,然后再分别拿对方的锁
//如果没有 sleep,执行顺序就不可控,可能会出现某个线程一下拿到两把锁,另一个线程还没执行,导致无法构造出死锁
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker2) {
System.out.println("t1 加锁 locker2 完成");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (locker2) {
System.out.println("t2 加锁 locker2 完成");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker1) {
System.out.println("t2 加锁 locker1 完成");
}
}
});
t1.start();
t2.start();
}
}
运行结果:
3) N 个线程,M 个锁(经典模型:哲学家就餐问题)
现有五位哲学家,他们坐在一个圆桌旁边,每个人左手边有一根筷子,圆桌中间是他们要吃的大碗宽面
任何一个科学家要想吃到面条都需要拿起左手和右手的筷子,他们现在只会做两件事:1) 思考人生,放下手里的筷子;2) 吃面条,拿起左右手两边的筷子
通常情况下,这个模型是可以运行的,但是当所有哲学家在某一时刻,同时想要吃面条,同时拿起了自己左手边的筷子,他们就拿不到右手边的筷子了,由于这些哲学家很固执,当他们吃不到面条的时候,绝不会放下左手的筷子,此时就形成了死锁
总结:死锁的四个必要条件(缺一不可)
1) 锁是互斥的(锁的基本特性)
2) 锁是不可被抢占的(线程 1 拿到了锁 A,如果线程 1 不主动释放 A,线程 2 不能把锁 A 抢过来)(锁的基本特性)
3) 请求和保持:线程 1 拿到锁 A 之后,不释放 A 的前提下,去拿锁 B;如果是先释放 A ,再拿 B 就不会有问题(特殊情况,有些代码里面就需要写成请求保持的方式)
4) 循环等待/环路等待/循环依赖(多个线程获取锁的过程中,存在循环等待...)
假设代码按照请求和保持的方式获取到 N 个锁,只需要给锁编号(1,2,3,N...),约定所有的线程在加锁的时候都必须按照一定的顺序来加锁(比如,必须先针对编号小的锁加锁,后针对编号大的锁加锁)
哲学家就餐问题解决方案:
假设同一时间,所有哲学家拿起第一根筷子
给筷子编号(1~5),规定每个哲学家每次拿筷子都要从小往大拿,即:
第一位哲学家不能拿左手边的2号筷子,先拿起右手边的1号筷子
第二位拿起右手边的2号筷子
第三位拿起右手边的3号筷子
第四位拿起右手边的4号筷子
第五位不能拿左手边的5号筷子,阻塞等到右手边的1号筷子被放下
由于5号筷子是空闲的,第四位就可以拿起5号筷子并吃到面条,之后就会释放4号5号筷子,3号就可以拿起4号筷子吃面条......
只要遵守上述的拿起筷子的顺序,无论接下来这个模型运行顺序如何,出现怎么极端的情况都不会出现死锁了