日升时奋斗,日落时自省
目录
简单提及一下前面的缺省内容:
线程引用:
public static Thread currentThread(); | 返回当前线程对象的引用 |
静态方法 直接说是类方法更好
这里用到了static 调用这个方法 不需要实例,直接通过类名来调用
Thread t=new Thread();
Thread.currentThread();
//t.currentThread();
直接用类来调用 返回值正是t这个引用指向的对象
线程休眠:
public static void sleep(long millis) |
让线程休眠,这个我们之前都是见过的,就阻塞了,当前线程不参与调度(不在cpu上执行)
操作系统内核:
这个链表里的PCB都是“随叫随到”,就绪状态
1、 线程的状态
状态是针对当前线程调度的情况来描述的
线程是调度的基本单位了,状态更应该是线程的基本属性,(之后提及的状态是线程状态)
针对java 状态细化
(1)NEW 创建Thread 对象,但是还没有调用start(内核里还没有创建一个新的线程)
(2)TERMINATED 表示内核中的线程已经执行完毕了,但是Thread对象还在
(3)RUNNABLE 可运行的
运行划分为两种情况:
第一种: 正在CPU上执行的 (就你正在用的程序)
第二种 : 在就绪队列里,随时可以去CPU上执行 (打开了,但是当前没有使用,后台放着)
(4)WAITING
(5)TIMED_WAITING
(6)BLOCKED
后三个也就是 (4)、(5)、(6)都时不同阻塞情况的状态
下面用一个图像来解释一下6个状态
那这里的TERMINATED 结束 后,引申问题:为什么Thread对象没有销毁,那不销毁还能在次使用吗?
线程的PCB消亡,代码中t对象也就是没有用了,线程已经结束,自然这个对象也就没有意义了
存在是因为java对象的生命周期,Thread对象没有指向引用了,java会自动回收的,生命周期并不是和系统内核线程一致,内核线程释放,代码中对象不一定立即释放
当前t就是一个“无效”的对象(还可以调用一些属性方法),start也能调用一次,一个线程只能是start一次
为什么start只能使用一次: 避免了t.start使用后,在重启过后是否是有效的无法判断,减少了不必要的麻烦
public class ThreadDemo12 {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
for (int i = 0; i < 100; i++) {
for (int j = 0; j < 100_0000; j++) {
int a = 10;
a += 10;
}
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
//启动之前,获取t的状态 ,就是NEW 状态
System.out.println("start 之前" + t.getState()); //获取当前状态
t.start(); //创建线程,并执行run方法
for (int i = 0; i < 1000; i++) {
System.out.println("t 执行中的状态" + t.getState());
}
t.join(); //等待线程执行结束
//线程结束完毕之后,就是TERMINTED状态
System.out.println("t 结束之后" + t.getState());
}
}
下面图解:解释运行阶段打印一次的状态 ,上面附的是打印多次的代码,可以尝试一下
解释多次打印后友友可能 想到的问题:
多次打印后会出现TIMED_WAITING状态的次数比较多 :
2、多线程的好处
单线程和多线程之间对比,执行速度的差别
程序分开执行
CPU密集,包含了大量的 加减乘除 等算术运算
就拿这个作为实例 解释单线程 和多线程的区别
多线程:在CPU密集型的任务中,有非常大的作用,可以充分利用CPU的多核资源,从而加快程序的运行效率
多线程总结:多个线程开始执行,同时提升效率,执行时间是线程最长时间
注:不是说使用多线程,就能一定提高效率
(1)是否是多核(现在CPU基本都是多核)
(2)当前核心是否有运行(CPU这些核心已经都满载,这个时候启动更多的线程也没有用了)
IO密集型的任务中,也有作用
简单解释:数据文件,就涉及到大量的读硬盘操作,阻塞了界面的响应,多线程也会起到改善作用(例如,游戏在数据缓冲的时候,就会导致你双击后,界面迟迟显示不出来)
3、线程安全
多线程的抢占式执行,带来的随机性(来自操作系统内核,改不了),就是最大的线程安全问题
如果没有多线程,此时的代码执行顺序就是固定的。单线程按代码顺序执行
如果有了多线程,此时抢占式执行,代码执行顺序,会出现更多的变数,但是又要这每一条执行顺序都是安全的
只要有一种情况下,代码不正确,都被视为有bug,线程不安全(对bug的解释)
还记得刚刚说到多线程好处没有处理的一个问题,为什么每次时间都是不一样的?
这里举出以下这个实例:
class Counter{
public int count=0;
public void add(){
count++;
}
}
public class ThreadDemo11 {
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) {
e.printStackTrace();
}
//打印最终的count值
System.out.println("count ="+counter.count);
}
}
那调度是随机,如何随机,count++中指令如何进行 图解解释:
这里简单的举了六个例子,前两个按顺序执行,没有线程安全问题,后面的四个都有线程安全问题
下面图解说明线程问题:
(1)无线程安全问题:(顺序执行)
可以直接有效的进行count++,不会发生count++后只出现一次自增结果(这里执行了两次count++操作,得出结果是2)
(2)有线程安全问题
count++操作执行两次但是count得出的结果只有1,产生“脏读”问题
已经执行两个线程的指令,但是count只加了1(出现了“脏读”问题)
此处多线程问题和前面并发事务都是“并发编程”问题,这个问题有一定是基于多线程这样的
一个线程具体执行,就需要先编译CPU指令的任何一个代码都是要编译成很多CPU指令的
当前代码 的指令有哪些
这里计算出来的count肯定小于10w,那他一定大于5w吗?不一定大于,刚刚给举了一个例子,仅仅限于5w的底线,如果是一下的情况呢
4、 线程问题原因
4.1、安全问题原因
抢占式执行,随机调度,不能改变
4.2、代码结构
多线程同时修改同一个变量(可以调整代码规避问题,特殊情况下可以)
(1)单线程修改变量,不能有问题
(2)多线程修改同一个变量,不能有问题(主要是这个问题大)(String是不可变对象,天然安全保护)
(3)多线程修改不同变量,不能有问题
4.3、原子性
如果修改操作是原子的,还是比较安全的
原子:就是单一的,或者一个整体不能更改,例如:加锁后一个整体就是原子。不可拆分的基本单位,或者是一条单个的指令也是一个“原子”
count++这里已知是三个操作load、add、save(这里随便的一个指令都无法再拆分)
如果count++操作是原子的,线程安全问题就解决了(当前就是“脏读”问题)
这里脏读问题解决就像“并发编程”问题一样,“加锁”变成原子问题迎刃而解
4.4、内存可见性问题
内存可见性问题是什么:在开始我也并不是很理解(这里附一个代码用于下面文字解释)
//volatile 关键词 内存可见性
class MyCounter{
public int flag=0;
}
//睡眠是不是能和volatile不能在同一个地方使用的,如果一起使用就会导致不生效,玄学问题,主要是我们不能感知到操作系统
public class ThreadDemo16 {
public static void main(String[] args) {
MyCounter myCounter=new MyCounter();
Thread t1=new Thread(()->{
while(myCounter.flag==0){
}
System.out.println("t1 循环结束");
});
Thread t2=new Thread(()->{
Scanner scanner=new Scanner(System.in);
System.out.println("请输入一个整数");
myCounter.flag=scanner.nextInt();
});
t1.start();
t2.start();
}
}
以下举一个例子:
代码执行前解释:
代码执行后图解:
该情况就是内存可见性问题:是不是还是有点不明白,是的,其实到这里那时的我也是很明白,接下来继续解释:
内存可见性问题总结解释:
(1)一个线程(t1线程)针对一个变量(flag)进行读取,同时另一个线程(t2)针对这个变量(flag)进行修改,此时(t1线程)读到的值,不一定是修改之后的值
(2)这个读线程(t1线程进行读操作)没有感知到变量的变化(编译器/jvm在多线程环境优化时产生了误判)
这里解释一下为什么感知不到:
汇编大概需要两步操作(这个循环操作是执行很快的,一秒中执行百万次):
(1)load把内存中flag的值,读取到寄存器里
(2)cmp把寄存器的值,和0进行比较(这里是根据上面的代码进行解释的),根据比较结果,决定下一步往哪个地方执行(条件跳转指令)
循环执行这么多次,在t2真正修改之前,load得到的结果都是一样的
另一方面,load操作和cmp操作相比,速度慢非常多(其实得记一下这里,想理解的话,可以和内存和CPU寄存器进行对比)
注:可以把load和cmp可以类比内存的速度很快,远远快与CPU寄存器
解释:这里就导致JVM出现一个认知错误,load执行速度太慢了(相当于cmp),再加上load的结果都是一样的,JVM的编译优化就做了个非常大胆的决定,没有人改动flag,那就读一次就行了(事实就是如此,友友不信可以试试,我亲测输入很多次,依旧没有用)
这是就需要我们手动让JVM知道一下, 上关键词 : volatile,给变量加上 volatile修饰,意思就是告诉编译器,这个变量是“易变”,每次都记得从新读取。
volatile内存可见性:使用注意:
(1)记得是只能修饰变量 (重点),如果需求变量多了怎么办,挨个加,其实它跟权限很像对一个变量的修饰(我个人见解,不用太当真)
(2)视情况而定,看需求分析,加volatile自然是会降低代码的执行速度
(3)什么时候使用,一个线程读一个线程写,可以考虑使用
代码修改的地方:
volatile不保证原子性:原子性,是靠synchronized来保证的
synchronized和volatile都能保证线程安全
因为volatile不能保证原子性,所以解决不了线程并发问题 例如:在见面出现的++并发问题
总而言之:内存可见性问题就把 synchronized 和volatile都用上就行了
4.5、指令重排序(编译优化)
这个前面的博客也提起过,编译器很根据你的代码进行优化做已调整(调整代码顺序),保证逻辑不变,加快程序的运行执行效率
5、解决线程问题
原子性来解决问题:“加锁” 成为一个整体,把不是原子的,转换成一个“原子”的
加锁的关键字:synchronized (加锁会单独作为一部分进行详细解释)
我们上面举例的代码加上关键字
注:java中 会synchronized会进行加锁,也会进行解锁(C++,Python还是需要手动加锁、手动解锁),我感觉java还是方便很多嘞
如果两个线程同学尝试加锁,此时一个能获取锁成功,另一个只能“阻塞等待”(BLOCKED)一直阻塞到刚才那个线程解锁,当前线程才能加锁成功
解决刚才的线程安全问题:
那给我们代码加上关键字synchronized 看是否解决了
看似关键字synchronized修饰了 add方法,但是不要这样理解,锁是加给对象,这里是修饰this对象,不是说真的是给方法加了锁,谁调用这个方法谁就会加锁(注:是给对象加锁,给对象加锁,给对象加锁,不要自省脑部给方法加锁)
注:修改的是上面提供的“线程安全”部分附的代码,只改这一处即可(如果有不对,可以留言)
运行结果:
我曾经对锁的疑惑:
友友可以参考:
上面看到加锁后的效果,这个就根据你需要的情况而定了,不是说加锁就一定好,当前了关于“钱”这种涉及的就是越严谨越好,加锁就会能用的上,当需要速度,对准确性需求降低的时候,就可以采取不加锁。
注:思维不要限制 ,锁不是就synchronized这个一个关键词,还有一种锁ReentrantLock这个锁是可以使用这个策略的(获取不到就放弃)
5.1、synchronized使用方法
(1)修饰方法:普通方法,静态方法
普通方法:锁对象是this
静态方法:和修饰一般方法 大体相同
(2)修饰代码块 : 显示/手动指定锁对象
构造方法尝试:
直接给构造方法加修饰是不可以的,可以用静态代码块加锁修饰
是否会产生锁冲突
(1)加锁,是要明确执行那个对象加锁的
(2)如果两个线程针对同一个对象加锁,会产生阻塞等待(锁竞争/锁冲突),一个线程能够获取到锁(先到先得)另一个线程“阻塞等待”,等待到上一个线程解锁,它才能获取成功
(3)如果两个线程针对不同的对象进行加锁,会产生阻塞等待(不会有锁竞争/锁冲突),这两线程都能获取到各自的锁,不会有阻塞等待了
(4)两个线程,一个线程加锁,另一个线程不加锁,这个时候也没有锁竞争
第(4)图解释(简单写):
总结:锁是同一个对象(产生阻塞),不是同一个对象(不会产生阻塞)
6、加锁(很重要)
锁的来历:monitor lock 监视器锁,synchronized的名字来历,异常中可见
在加锁的时候也会检查对象,当线程和加锁线程是否是同一个,是同一个那能执行,如果不是就阻塞呗
除了java这里的synchronized之外,其他语言大多都是分开操作锁,加锁(lock),解锁(unlock)分开。
synchronized是一个“原子”指令,不会在过程被调度,那这个指令是如何启动的,CPU提供了加锁指令,操作系统实现锁,操作系统把API提供给JVM,JVM将功能提供给synchronized
6.1、可重入
可重入:一个线程针对同一个对象,连续加锁两次,是否会有问题,如果有问题就说明不可重入,如果没有问题就是可重入
java里这种代码比较常见,所以根据java的设定为了避免不小心引发的死锁,synchronized被设定为可重入,其他语言(C++,Python都是不可重入的)
可重入文字解释不好想出来,代码解释
可重入其实理解字面意思就行:可以重新进入
实例解释:
这样举一个层叠梦的例子:不知道友友们有没有做过梦中梦
6.2、java标准库线程安全类
在前面String类博客提过一点点线程,String不可改,所以是天然的线程安全
StringBuilder和StringBuffer在叙述时,也提及过,两者具有相同的方法,但是StringBuffer线程安全,因为如果多线程操作同一个集合类,就需要考虑到线程安全的事情
java标准库部分集合类是被直接加锁了的,自带的
Vector (不推荐使用)
HashTable (不推荐使用)
ConcurrentHashMap
StringBuffer
相对来说,内部加锁的更安全,但是慢一点点,强行加锁
没有加锁的集合类
ArrayList
LinkedList
HashMap
TreeMap
HashSet
TreeSet
StringBuilder
这些类没有加锁,相对于多线程就有较大的风险了,当然了学了锁不是任何地方都加,是让我们注意多线程带来的问题,不是说是个多线程就有问题,没有线程安全问题,这些集合也可以使用,有线程问题手动加锁解决,更自由,就是使用起来要更谨慎
6.3、死锁
死锁的影响力很大,一旦出现死锁,无法进行后续工作,程序势必互有严重的bug,死锁也很隐蔽,在写代码期间并不容易看出来,不容易测试出来。
在可重入时就提到死锁了
(1)一个线程,一把锁,二次加锁,如果是不可重入锁,就会死锁
前面提到在java中synchronized和ReentrantLock都是可重入锁,其他语言应该能见该现象
(2)两个线程两把锁,但是两个线程都想要两把锁,t1线程拿锁一,t2线程拿锁二,这样就成了死锁了,为什么呢,因为t1线程在等t2线程解锁获取锁二,t2线程在等t1线程解锁获取锁一
举个例子:两个小孩子,吃卷饼,都想加一点沙拉酱和番茄酱,,但是出于小孩子喜欢自己优先,小孩一号拿到了沙拉酱,小孩二号拿到了番茄酱,在交换酱的时候出于好胜心,想先用,一号小孩想先要二号小孩的番茄酱,二号小孩想要一号小孩的沙拉酱,此时出现死锁问题,谁也不愿给谁也拿不到
死锁代码:
public class ThreadDemo14 {
public static void main(String[] args) {
Object slj = new Object(); //沙拉酱
Object fqj = new Object(); //番茄酱
Thread child_1 = new Thread(() -> { //一号小孩
synchronized (slj) { //加了沙拉酱锁
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (fqj) { //加番茄酱锁
System.out.println("一号小孩有了沙拉酱和番茄酱");
}
}
});
Thread child_2 = new Thread(() -> { //二号小孩
synchronized (fqj) {//加了番茄酱锁
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (slj) {//加沙拉酱锁
System.out.println("二号小孩有了番茄酱和沙拉酱");
}
}
});
child_1.start();;
child_2.start();
}
}
运行结果:啥也没没有因为两者互不相让
如何来看锁的问题
或者直接调试idea 也会弹出一个,但是不是太具体,可能是我不太会用吧
给友友们在这里简单提示一下操作:
idea调试运行之后,然后去点击debug
这里sleep是为了确保两个线程先把第一个锁拿到,否则不容易造成当前局面,解释该死锁
死锁问题如何解决,当前先不说,先往下看,自然会有解开之法(不是在这里卖关子,只是现在不好解释)
(3)多个线程,多把锁
操作系统书上的经典案例:哲学家就餐问题
死锁的四个必要条件:
(1)互斥使用: 线程1 拿到了锁,线程2就得等着 (锁的基本特性)
(2)不可抢占: 线程1 拿到锁之后,必须是线程1 主动释放。不能说是线程2就把锁给强行获取
(3)请求和保持 : 线程1 拿到A锁之后,再尝试获取锁B, A这把锁还保持(不会因为获得的B锁就把A锁给释放了)
(4) 循环等待 :线程 1 尝试获取到锁A和锁B 线程2 尝试获取到锁B和锁A,线程1在获取B的时候等待线程2释放锁B;同时线程2 在获取A的时候等待线程1释放锁A
锁虽然有四个点,但是相对于java语言只有一种是我们可以更改的,(4)我们可以更改的,其他三条都是已经不能改变的,循环等待是这里四个条件里唯一一个核代码相关的,也是我们可以控制的
避免死锁?只要让循环等待中开出一个突破口就解决问题了,哲学家就餐问题中,不难看出,在一个循环等待的过程中,只要有一个循环点结束了当前阻塞,其他点的就迎刃而解了。
办法:给锁编号,然后指定一个固定的顺序(比如从小到大)来加锁,这方法是自己指定的,这里针对刚刚死锁的代码进行更改,自己依照某个顺序可以解决死锁问题即可
解决死锁,最简单的办法;
回到刚刚死锁的代码中(更改):
书上可以还会学到一个解决死锁的办法 叫做银行家算法也能解决,但是实际开发中过于复杂一旦出错,就会引来其他的bug不好更改