多线程-初阶2
承接 认识线程、Java多线程编程、Thread类及常见方法!!!
一、线程的状态
状态是针对当前线程调度情况来描述的。线程是调度的基本单位,状态是线程的属性 (后面谈到状态都是针对线程)
Java对于线程的状态进行了细化。
1.1 线程的所有状态
线程的状态是一个枚举类型 Thread.State 。
public class ThreadState {
public static void main(String[] args) {
for (Thread.State state : Thread.State.values()) {
System.out.println(state);
}
}
}
- NEW: 安排了工作,还未开始行动
- RUNNABLE: 可工作的,又可以分成正在工作中和即将开始工作
a) 正在CPU上执行
b) 在就绪队列里随时可以去CPU上执行 - TERMINATED: 工作完成了
- TIMED_WAITING: 排队等着其他事情
- BLOCKED: 排队等着其他事情
- WAITING: 排队等着其他事情
注意:
1.Java中没有running状态,无法区分当前是RUNNABLE的哪种情况,像Linux这样的操作系统也是区分不了的。不区分这俩也没关系,因为还是把线程调度的工作全权委托给系统了~~
2.后三个状态都表示阻塞 (都表示线程PCB正在阻塞队列中),但这三个状态是不同原因的阻塞。
PCB没了,但对象还在。可以调用对象的一些方法属性,但是没法通过多线程来做一些事情了!
t 线程对象,如果TERMINATED之后还有重新启用的机会,程序猿就不好判定当前这里的 t到底是一个有效的还是无效的;如果明确TERMINATED就是终结,没有重新 start的机会了,此时程序猿就可以心安理得地放弃t,同时后续任何代码中使用了 t的线程都可以视为是不太科学的操作了~~
所以,之前我们学过的 isAlive() 方法,可以认为是处于不是 NEW 和 TERMINATED 的状态都是活着的。
1.2 示例观察
观察1: 关注 NEW、RUNNABLE、TERMINATED状态的转换
- 使用 isAlive 方法判定线程的存活状态
public class ThreadStateTransfer {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
for (int i = 0; i < 1000_0000; i++) {
// 什么都不干
}
}, "李四");
System.out.println(t.getName() + ": " + t.getState());;
t.start();
while (t.isAlive()) {
System.out.println(t.getName() + ": " + t.getState());;
}
System.out.println(t.getName() + ": " + t.getState());;
}
}
观察2: 关注 RUNNABLE、TIMED_WAITING状态的转换
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
for (int i = 0; i < 1000_0000; i++) {
try{
Thread.sleep(10); // 实际sleep时间很久,因为外面还套了一个大循环!
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}, "李四");
System.out.println(t.getName() + ": " + t.getState());;
t.start();
for(int i = 0;i < 100;i++){
System.out.println(t.getName() + ": " + t.getState());
}
t.join();
System.out.println(t.getName() + ": " + t.getState());;
}
}
RUNNABLE / TIMED_WAITING取决于当前咱们的t线程是运行到哪个环节了!
BLOCKED、WAITING接下来会讲解~
二、多线程意义-加快执行速度
2.1 示例
案例:多线程的意义是什么呢?
写个代码来感受一下单个线程和多个线程之间执行速度的差别。
程序分为CPU密集和IO密集。
CPU密集包含了大量算术运算和逻辑运算;
IO密集涉及到读写文件、读写控制台、读写网络。
这里我们来分析CPU密集型~
两个变量各自增100亿次的时间?
(currentTimeMillis 获取到当前系统的ms级时间戳)
单线程执行:
public class ThreadDemo {
public static void main(String[] args) {
serial();
}
public static void serial(){
// currentTimeMillis 获取到当前系统的ms级时间戳
long beg = System.currentTimeMillis();
long a = 0;
for(long i = 0;i < 100_0000_0000L;i++){
a++;
}
long b = 0;
for (long i = 0; i < 100_0000_0000L; i++) {
b++;
}
long end = System.currentTimeMillis();
System.out.println("执行时间" + (end-beg) + " ms");
}
}
使用两个线程分别完成自增:
public class ThreadDemo {
public static void main(String[] args) {
concurrency();
}
public static void concurrency(){
// 使用两个线程分别完成自增!
Thread t1 = new Thread(() -> {
long a = 0;
for (long i = 0; i < 100_0000_0000L; i++) {
a++;
}
});
Thread t2 = new Thread(() -> {
long b = 0;
for (long i = 0; i < 100_0000_0000L; i++) {
b++;
}
});
long beg = System.currentTimeMillis();
t1.start();
t2.start();
try{
t1.join();;
t2.join();;
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
long end = System.currentTimeMillis();
System.out.println("执行时间" + (end-beg) + " ms");
}
}
2.2 总结
多线程在这种CPU密集型的任务中有非常大的作用,可以充分利用CPU的多核资源从而加快程序的运行效率!
多线程在IO密集型的任务中,也是有作用的 (代码上不好演示)~
三、多线程带来的的风险-线程安全 (重点)
3.1 线程安全的概念
想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:
如果多线程环境下代码运行的结果是符合我们预期的 (即在单线程环境应该的结果),则说这个程序是线程安全的。
线程安全问题的罪魁祸首、万恶之源就是,多线程的抢占式执行,带来的随机性!
如果没有多线程,此时程序代码执行顺序就是固定的 (只有一条路),所以结果也是固定的;如果存在多线程,此时抢占式执行下,代码执行的顺序会出现更多的变数,代码执行顺序的可能性就从一种情况变成了无数种情况!所以就需要保证这无数种线程调度顺序的情况下,代码的执行结果都是正确的!!!只要有一种情况执行结果不正确就都视为有bug,即线程不安全~~
3.2 观察线程不安全
class Counter{
public int count = 0;
public void add(){
count++;
}
}
public class ThreadDemo {
public static void main(String[] args) {
Counter counter = new Counter();
// 搞两个线程,分别针对 counter 来调用 5w 次的 add() 方法
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);
}
// 打印最终的 count 值
System.out.println("count = " + counter.count);
}
}
3.3 分析问题
上面的线程不安全的代码中,涉及到多个线程针对counter.count变量进行修改,此时这个counter.count是一个多个线程都能访问到的”共享数据”。
counter.count 这个变量就是在堆上,因此可以被多个线程共享访问。
甚至一个线程走三步的过程中,另一个线程走了两个三步:
因此会有无穷种组合!!!且count结果 > < = 50000 都有可能~~
前两种情况是正确的,没有线程安全问题;之后的情况都会出现”后一次自增覆盖了前一次的结果”的问题!!!
其实就和事务读未提交read uncommitted是一样的,相当于t1读到的是一个t2还没来得及提交的脏数据,于是就出现了脏读问题!
此处讲的多线程和前面的并发事务本质上都是 并发编程 问题!并发处理事务,底层也一定是基于多线程这样的方式来实现的~~
3.4 进一步理解 CPU指令
CPU有个重要的组成部分:寄存器。
寄存器也能存数据,但空间更小,访问速度更快!CPU进行的运算都是针对寄存器中的数据进行的~~
CPU里的寄存器有很多种:
1.通用寄存器 (用来参与运算的),如EAX、EBX、ECX…
2.专用寄存器 (有特定功能的),如EBP、ESP、EIP
保存上下文时:用PCB里的内存把当前所有的寄存器都给保存起来!
机器指令就是汇编,机器指令就是直接在CPU上运行的,势必要经常操作寄存器~
3.5 线程不安全的原因
到底是什么样的情况会出现线程安全问题?是所有的多线程代码都会涉及到线程安全问题吗??
3.6 从原子性入手解决
如何从原子性入手来解决线程安全问题?
加锁! 通过加锁,把不是原子的转成”原子”的~~
即使 t1这会儿没在CPU上执行,但是没有释放锁,t2仍然得阻塞等待!
class Counter{
public int count = 0;
synchronized public void add(){
count++;
}
}
............
运行结果:
达到预期~~ 加锁之后,线程安全问题就得到改善了!
详解请看第四章:加锁与死锁!~~
3.7 Java标准库中的线程安全类
Java标准库中很多都是线程不安全的,这些类可能会涉及到多线程修改共享数据,又没有任何加锁措施:(如果多个线程操作同一个集合类,就需要考虑到线程安全问题)
StringBuffer的核心方法都带有synchronized
线程安全的为什么不推荐使用?为啥不都加上锁呢??
因为加锁这个操作是有副作用:额外的时间开销的!!!
还有的虽然没有加锁,但是不涉及 “修改”,所以仍然是线程安全的:
- String
四、加锁与死锁详解(JVM)
4.1 synchronized的使用
监视器锁monitor lock:JVM给synchronized起了这个名字,因此代码中有时候出异常可能会看到这个说法。
所以是线程对对象加锁,锁不是加到方法上的!!!(虽然synchronized是修饰了方法)
我们重点要理解,synchronized锁的是什么,两个线程竞争同一把锁,才会产生阻塞等待!
“同一把锁”是看针对的是否为“同一个对象”!
synchronized的力量是JVM提供的;jvm的力量是操作系统提供的;操作系统的力量是CPU提供的。
追根溯源,是CPU提供了加锁这样的指令才能让操作系统实现锁;操作系统把锁的API提供给JVM;JVM提供给synchronized。
4.2 可重入
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。
一个线程针对同一个对象,连续加锁两次,看是否会有问题~~ 如果没问题,就叫可重入的;如果有问题,就叫不可重入的。
因为在Java中类似代码是很容易出现的,为了避免不小心就死锁,Java就synchronized设定成可重入的了,即可重入锁!!!(但是C++、Python、操作系统原生的锁,都是不可重入的…)
在可重入锁的内部,包含了 “线程持有者” 和 “计数器” 两个信息。
如果某个线程加锁的时候,发现锁已经被人占用,但是恰好占用的正是自己,那么仍然可以继续获取到锁,并让计数器自增,解锁的时候计数器递减为 0 的时候,才真正释放锁。(才能被别的线程获取到)
4.3 死锁
死锁是一个非常影响程序猿幸福感的问题。
一旦程序出现死锁,就会导致线程直接跪了 (无法继续执行后续工作了)!程序势必会有严重bug。死锁非常隐蔽,开发阶段不经意间就会写出死锁代码,且不容易测试出来。
4.3.1 死锁的几种情况
1) 一个线程,一把锁连续加锁两次,如果锁是不可重入锁,就会死锁!
Java里synchronized和ReentrantLock都是可重入锁,这个现象演示不了~~
2) 两个线程两把锁,t1和t2各自先针对锁A和锁B加锁,再尝试获取对方的锁。
举个例子,东北地区人吃饺子蘸酱油;西北地区人吃饺子蘸醋。有天一个东北人和一个西北人结伴吃饺子,两人分别先拿了酱油和醋,然后同时想要获取到对方的醋 / 酱油。如果两人互不相让,就僵持住了,这就是死锁!
public class ThreadDemo {
public static void main(String[] args){
Object jiangyou = new Object();
Object cu = new Object();
Thread dong = new Thread(() -> {
synchronized (jiangyou){
try {
Thread.sleep(1000); // 确保线程先把第一个锁拿到,否则不容易构造出来,因为线程是抢占式执行的!
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (cu){
System.out.println("东北人把酱油和醋都拿到了!");
}
}
});
Thread xi = new Thread(() -> {
synchronized (cu){
try {
Thread.sleep(1000); // 确保线程先把第一个锁拿到,否则不容易构造出来,因为线程是抢占式执行的!
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (jiangyou){
System.out.println("西北人把酱油和醋都拿到了!");
}
}
});
dong.start();
xi.start();
}
}
Thread.sleep(1000);
是为了确保两个线程先把第一个锁拿到,否则不容易构造出来,因为线程是抢占式执行的!
运行结果:
当前这里没有任何日志,说明没有线程拿到两把锁!
使用jconsole看一下线程的情况:
针对这样的死锁问题,也是需要借助像jconsole这样的工具来进行定位的,观察线程的状态和调用栈,就可以分析出代码是在哪里死锁了!
3) 多个线程多把锁 (相当于 2) 的一般情况)
教科书上的经典案例,哲学家就餐问题:
每个哲学家有两种状态:
1.思考人生 (相当于线程的阻塞状态);
2.拿起筷子吃面条 (相当于线程获取到锁然后执行一些计算)。
由于操作系统随机调度,这五个哲学家随时都可能想吃面条,也随时可能要思考人生~
要想吃面条,需要拿起左手和右手的两根筷子!
假设出现了极端情况,就会死锁!:
同一时刻,所有的哲学家同时拿起左手的筷子!这时所有的哲学家都拿不起来右手的筷子,都要等待右边的哲学家把筷子放下~
4.3.2 死锁的四个必要条件
锁更多,线程更多,情况就更复杂了。分析一下死锁到底是怎样形成的?
4.3.3 打破死锁
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/118586.html