欢迎关注个人主页:逸狼
创造不易,可以点点赞吗~
如有错误,欢迎指出~
目录
线程状态
进程状态 分为两种:
- 就绪:正在cpu上执行,或者随时可以去cpu上执行
- 阻塞:暂时不能参与cpu执行
Java的线程状态有 6 种
- NEW 当前Thread对象虽然有了,但是内核的线程还没有(还没调用start)
- TERMINATED 当前Thread对象虽然还在,但是内核线程已经销毁(线程已经结束)
- RUNNABLE 就绪状态,正在cpu上运行 + 随时可以去cup上运行
- BLOCKED 因为锁竞争引起的阻塞
- TIMED_WAITING 有超时的等待 如sleep,或者join带参数版本
- WAITING 没有超时 时间的阻塞等待 如 join/wait
学习线程状态主要为了 调试,比如 遇到某个代码功能没有执行,就可以观察对应线程的状态,看是否是因为一些原因阻塞了.
线程安全
多个线程同时执行某个代码时,可能会引起一些奇怪的bug,理解线程安全才能 避免/解决 上述bug
代码示例
public class Demo12 {
public 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);
}
}
因为多线程并发执行 引发的bug称为"线程安全问题" 或"线程不安全"
解释
上述代码count++;在cpu视角是3个指令:
- load 把内存中的数据读到cpu寄存器里
- add 把cpu寄存器里的数据+1
- sava 把寄存器的值,写回内存
指令是cpu执行的基本单位,如果要调度,cup至少会把当前指令执行完,
cpu调度执行线程的方式是 抢占式执行,随机调度,
但是由于count++ 是三个指令,可能会出现cpu执行了其中1个指令或2个指令或3个指令就被调度走的情况(都有可能,无法预测), 所以 两个线程同时对count进行++ 就容易出现bug
由于循环5w次过程中不知道有多少次的执行顺序是前两种正确情况,有多少次是其他错误情况,最终的结果就是一个不确定的值,而这个值 一定小于10w
但结果也有可能小于5w
总结原因
- 线程在操作系统中,随机调度,抢占式执行(根本原因), 此原因无法干预(操作系统内核,作为应用层的程序员无法干预)
- 多个线程,同时修改一个变量(如果是一个线程修改,就没事)
- 修改操作,不是"原子"的,(对cpu来说,一条指令才是"原子"的,是不可分割的最小的单位)
解决方案-->加锁
解决线程安全问题,最主要的方法就是 把"非原子" 的修改,变成"原子"(通过加锁,把非原子的修改操作 打包成一个整体,变成原子操作)
t1和t2都加锁 且 同一个锁对象
此处的加锁,没有干预到线程的调度,只是通过加锁,使一个线程在执行count++时,其他线程的count++不能插队进来
Java提供synchronized关键字 来完成加锁操作,synchronizede()的'()'中需要指定一个 "锁对象" (可以指定任何对象)来进行后面的判定
t1和t2都是针对locker对象加锁,t1加锁成功后,继续执行{}里的代码,t2后加锁,发现locker对象已经被别人先锁了,t2只能排队等待(这两者的++ 操作不会并发执行了,本质上是把随机并发的执行过程 强制变成了串行,从而解决了刚才的线程安全问题)
t1和t2中只有一个加锁了
t1和t2都加锁,但锁对象不同
锁对象的作用 就是用来区分 两个线程或多个线程 是否针对"同一个对象"加锁
- 若是,此时就会出现阻塞(锁竞争/锁冲突)
- 若不是,此时不会出现"阻塞",两个线程仍然是 随机调度的并发执行.
锁对象,只要是Object(或者其子类)都行,不能是int,double这样的内置类型
加锁 与线程等待(join) 的区别
上述加锁后的代码 本质上要比join的串行执行 的效率还要高
- 加锁只是把线程中一小部分逻辑 变成了 串行执行,剩下其他部分仍然可以并发执行
- join是 线程 整体都串行执行
使用类对象加锁
一个Java进程中,一个类的类对象是只有唯一一个的,类对象,也是对象,也可以成为锁对象,写类对象和写其他对象 没有本质区别,换句话说,写成类对象,就是'偷懒'的做法(不想单独创建锁对象了~)
加锁场景
是否要加锁,怎么加锁,都是和具体场景直接相关的("无脑加锁"是不推荐的)
锁 ,需要的时候才使用,不需要的时候不要使用,否则会付出代价(性能)
使用锁,就可能会发生阻塞,一旦某个线程阻塞,啥时候恢复阻塞 继续执行是不可预期的~
死锁
场景1
一个线程,针对一把锁,连续加锁两次
Java的synchronized做了特殊处理(引入了特殊机制,"可重入锁"),不会出现 死锁,但同样的代码换成c++/python就会死锁
可重入锁就是在锁中 额外记录一下 当前是哪个线程,对哪个锁加锁了,后续加锁时就会进行判定
还会引入一个引用计数,维护当前已经加锁几次了,并且描述何时真正释放锁
场景2
两个线程,两把锁
- 线程1先针对A加锁,线程2针对B加锁
- 线程1不释放锁A的情况下,在针对B加锁,同时线程2不释放锁B的情况下对A加锁
这种情况,可重入锁 也无能为力~
package thread;
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个锁
经典问题:哲学家就餐问题
当每根筷子都被哲学家左手拿起来了,他们右手就没有筷子可以拿了,当哲学家吃不到面条时,也就不会放下左手的筷子, 此时就产生了 死锁.
构成死锁的4个必要条件(缺一不可)
锁的基本特性:
- 1.锁是互斥的 . 如 一个线程拿到锁,另一个线程就拿不到这个锁
- 2.锁是不可被抢占的. 如 线程1拿到了锁A,若线程1不主动释放A,线程2不能把锁A抢过来
对于synchronized 这样的锁,互斥和不可抢占都是基本特性, 无法干预
代码结构上:
- 3.请求 和 保持. 如 线程1拿到锁A之后,不释放A的前提下,去拿锁(解决方法: 如果是 先释放A,再拿B,不会有问题)
- 4.循环等待 / 环路等待 / 循环依赖. 如 多个线程获取锁时,存在 循环等待( 解决方法:如果在获取多把锁的时候,不要构成循环等待就行了)
解决方案举例
针对场景2,通过改变代码结构 解决死锁问题(对症下药~)
方案1 避免锁嵌套
针对 死锁构成条件3
方案2 约定加锁顺序
针对 死锁构成条件4
给锁进行编号1,2,3,4...N,约定所有的线程在加锁时都必须按照一定的顺序加锁(比如,必须先针对编号小的锁,加锁,后对大的加锁)
方案3 银行家算法
但是银行家算法太复杂了,如果在日常开放中,实现一套银行家算法解决死锁,先不说死锁的问题是否存在,你实现的银行家算法本身可能存在bug~