作者主页:paper jie_博客****
本文作者:大家好,我是paper jie,感谢你阅读本文,欢迎一建三连哦。
本文于《JavaEE》专栏,本专栏是针对于大学生,编程小白精心打造的。笔者用重金(时间和精力)打造,将MySQL基础知识一网打尽,希望可以帮到读者们哦。
其他专栏:《MySQL》《C语言》《javaSE》《数据结构》等
内容分享:本期将会分享线程安全与线程状态~
目录
[加锁 - synchronized](#加锁 - synchronized)
[线程的通知等待 - wait和notify](#线程的通知等待 - wait和notify)
线程状态
线程的所有状态
1. NEW Thread对象创建好了,但还没有调用start()去系统中创建线程
2. RUNNABLE 调用了start(),线程正在执行或者准备就绪随时准备被调度
3. TERMINATED Thread对象还在,但是系统中的线程已经执行完销毁了.
4. TIMED_WAITING 有时间现在的堵塞状态,到达一定时间会解除堵塞
5. WATING 死等的堵塞状态,需要达到一定的条件才会解除堵塞
6. BLOCKED 由于锁竞争引起的堵塞
状态的意义
**状态存在的最大用处就是我们去调试多线程出现的bug时会给我们提供很大的参考意义.**比如: 程序卡住了,那可能就是一些相关的线程进入了堵塞状态. start()一个Thread对象只能使用一次这是和NEW密切相关的,只有在NEW状态才能使用start(),使用start()后就进入了另一个状态.
状态图
查看状态
我们可以通过JDK的jconsole来去查看进程里的线程的状态和调用栈的情况.我们可以根据这个来观察线程是不是堵塞了,为什么堵塞,执行到哪行堵塞了.
java
public class ThreadDemo5 {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
while(true) {
System.out.println("hello thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
thread.start();
while(true) {
System.out.println("hello main");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
线程安全
什么是线程安全
一段代码不论是在单线程上还是在多线程上都可以通过执行,不会出现bug,这就是"线程安全".
一段代码再单线程上可以通过,但是在多线程上会出现bug,这就是"线程不安全"或者"线程安全问题"
经典栗子
java
public class ThreadDemo6 {
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);
}
}
上述这个代码,我们预期的结果是100000,但是我们运行后发现结果不是100000.
原因
这就需要我们站在硬件的角度来看软件了. 我们知道count++在cpu中实际是三条指令:
1. load 去内存中拿到count的值放到cpu中的寄存器中
2. add 将寄存器中的值+1
3.将寄存器中的结果放到内存中
这里如果是一个线程执行结果肯定是不会出错的.但多个线程,那线程的调度也是随机的.这些指令的先后顺序就会产出多种情况.有的是正确的,有的是错的:
这里列出了几种情况,但其实这样的情况有无数种:
这里两个 线程是并发还是并行我们都不知道,反正两个线程都有自己的PCB,有各自的上下文,互不干扰(各自一套寄存器里的值,互不干扰).
通过观察我们发现,知道一个线程的save没执行,另一个线程的load执行了的话,那这个结果就不对,使用正确的情况应该是一个线程的save需要先执行完才能执行另一个线程的load.
导致线程不安全的原因
1. 根本原因: 这是因为操作系统线程是被随机调度的,抢占式执行,这可能就是导致指令的执行顺序不同.
**2. 代码结构: 多个线程同时改变一个变量.**这里多个线程改变不同变量,多个线程读一个变量,一个线程改变一变量是都不会造成线程安全问题的.
3. 直接原因: 代码没有具有原子性. 这里count++虽然只有一个代码,但其实它有三个指令.在执行到一半的时候可能会被调度走,其他的线程就有机可乘插队进来.这可能就会导致错误.这里我们可以将count++的多个指令理解为一个整体.需要全部执行完才能执行其他的指令.这样才具有原子性.
4. 内存可见
5. 指令重排序
解法方法
知道了这几个方面的原因我们就可以对症下药了:
第一个问题的随机调度是操作系统控制的,我们没法改变操作系统,我们无从下手.
第二个问题我们在写代码的时候需要注意代码的结构,避免出现多个线程同时改变一个变量的问题,但有的时候是无法避免的.
第三个问题我们可以通过加锁的方法来将需要执行的代码指令打包成一个整体,这样就具有原子性了.
加锁 - synchronized
加锁的目的就是为了将需要的代码打包成一个整体,令他们具有原子性.加锁的特点就是排他性,互斥性. 这里就是一个线程在执行加锁操作时,其他的线程是不能执行这个加锁对象里的代码的.
举个栗子:
这就像有一个厕所,多个滑稽需要上厕所,一个滑稽进去后将门关上其他滑稽进不来看不到就叫做加锁,上完厕所出去就叫做解锁.这时其他的滑稽才可以进来.
加锁如何操作
在加锁前,我们需要引入一个类对象,加锁和解锁都是依托这个类进行的.这个类对象可以是Object类或者是它的任意一个子类.加锁在Java中是一个关键字 - synchronized.它的括号里面放所对象,花括号里面就是加锁,花括号后就是解锁.
这里加锁的核心就是一个线程对一个所对象进行加锁了,其他的线程再对这个锁对象进行加锁就会导致堵塞.一直到前面的线程解锁才会解除堵塞.这里就是所谓的锁竞争造成的堵塞.
且我们需要知道原子性这个说法不够准确. 不是说加锁了这里里面的指令就一定会完成或者都不完成.它中途还是会被调度出去的.只是说第一个加锁的线程可以保证后面对这个所对象加锁的线程指令不会插队到第一个线程指令中间执行.并不是说不能调度出CPU.
加锁后的代码
java
public class ThreadDemo10 {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
Thread t1 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
synchronized(object) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
synchronized(object) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
我们可以发现这时的结果就是正确的了.
注意
这里需要注意几个点:
一个线程加锁,一个线程不加锁.或者不同的线程加不同的锁这都会造成线程安全问题.
需要加锁的线程的所对象必须是同一个.
这里this和类名.class也是可以作为所对象的.
this:
这里this就是直接指代的test.
java
class Test {
public int count = 0;
public void add() {
synchronized (this) {
count++;
}
}
}
public class ThreadDemo11 {
public static void main(String[] args) throws InterruptedException {
Test test = new Test();
Thread t1 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
test.add();
}
});
Thread t2 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
test.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + test.count);
}
}
类名.class:
这里因为java进程中的一个类只有一个类对象,这样不同的线程使用的还是同一个对象,锁竞争还是会存在.
java
class Test {
public int count = 0;
public void add() {
synchronized (Test.class) {
count++;
}
}
}
public class ThreadDemo11 {
public static void main(String[] args) throws InterruptedException {
Test test = new Test();
Thread t1 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
test.add();
}
});
Thread t2 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
test.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + test.count);
}
}
内存可见性问题
内存可见性问题和JVM的代码优化息息相关. 一个线程读,一个线程写也可能导致线程安全问题.
经典栗子
while(flag == 0)
这里我们预期的是通过输入1来跳过t1线程的循环,但是我们发现循环并没有跳过,光标还在闪烁.
原因
这里我们需要知道**while(flag == 0)**这句代码其实有两条指令:
1. lode 将内存的中flag读取到CPU的寄存器中
2. 将寄存器中的值与0比较(条件跳转指令)
整体的情况就是t1线程和main线程启动,mian线程需要等待输入,这之间至少需要几秒.而在这几秒的过程中while()会执行几百亿次.
关键有两点:
1. 在这多次lode中,读取内存中的值都是一样的,没发生改变.
2. 读取内存比条件跳转的开销大很多
这就会导致在等待输入这几秒中,大量的循环比较,其中去读取内存,读到的值却没有改变.读取内存的开销又特别大.这就会让JVM怀疑这样的操作有必要嘛.它就有可能会将读取内存指令删除只用寄存器中的值. 这就导致main中改变了flag,但t1线程却没看到,这就是内存不可见.
解决方法
内存可见性是高度依赖JVM的代码优化的具体实现,代码改变一点,结果可能就不一样.为了保证绝对性,Java中就引入了volatile关键字.它的作用就是保证内存可见. 它可以强制代码不进行优化,就是强制读取内存.
Java中锁的特性
互斥性
互斥性就是一个线程获取了这把锁,另一个线程再尝试获取就需要等待,这里就是锁竞争造成的堵塞.这个特性就是用来解决线程安全问题的.
可重入性
可重入性就是一个线程再使用一把锁的前提下,在嵌套二次使用这把锁.在这种情况下不会让线程卡死.
举个栗子:
java
public class TreadDemo12 {
public static void main(String[] args) {
Object object = new Object();
Thread thread = new Thread(() -> {
synchronized(object) {
synchronized (object) {
//写代码
System.out.println("hello word");
}
}
});
thread.start();
}
}
在这个代码中,如果不使用可重入锁,就会卡死,进入死锁状态.在C++中就没有可重入锁,就会陷入死锁状态. 这种死锁情况就是: 在一个线程里使用锁的前提下,嵌套第二次再使用这个锁.就会发生第一次这个锁对象已经加锁了,则第二次使用锁对象就需要等待第一次解锁,但第一个解锁在第二次加锁的后面.这就导致线程卡死,进入了死锁状态.
这就是相当于你将钥匙忘在了被锁的房间里.
在Java中就不会发生. 因为Java中的锁是可重入的. 由于是同一个线程,在第二次加锁的时候,就会直接放行,不会造成堵塞. 而Java中的锁可以重入是因为锁里面有两个重要的属性: 加锁线程 和 计数器
加锁线程这个属性会记录加锁的线程是谁. 计数器初始值为0,加锁就会+1,解锁就会-1.
在第一次加锁时,加锁线程就记录这个线程.计数器+1. 第二次就会判断加锁的线程和持有锁线程是不是同一个,是就直接计数器++,不是就堵塞等待. 出第二次加锁的括号,计数器就-1, 出第一次加锁的括号再-1,当计数器为0时,就是解锁成功.
死锁
加锁是对多线程的线程安全问题的解决方式,但是加锁操作不恰当就是会出现死锁问题.
死锁问题的常见三种情况
1. 一个线程一把锁:
一个线程中在持有这把锁的前提下,第二次使用这把锁,这就会导致死锁.但在Java中不会出现.
2. 两个线程两把锁:
一个线程在持有A锁的情况下去尝试获取B锁,同时另一个线程在持有B锁的情况下尝试获取A锁.这就会导致死锁.
栗子:
java
public class ThreadDemo13 {
public static void main(String[] args) {
Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
synchronized(A) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (B) {
System.out.println("在A加锁后,加锁B");
}
}
});
Thread t2 = new Thread(() -> {
synchronized(B) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized(A) {
System.out.println("在B加锁后,加锁A");
}
}
});
t1.start();
t2.start();
}
}
这就相当于车钥匙放到了被锁的房间里,房间钥匙放到了被锁的车里.
N个线程M把锁:
哲学家就餐问题
这里有5个哲学家,5个筷子. 一个哲学家就餐时需要使用两个身边的筷子. 当1滑稽就餐时需要1和5筷子,这是5滑稽和2滑稽想就餐就需要等待了.这样虽然需要等待但是最后还是可以吃上面.但是这里有一个极端的情况就是:所有的滑稽同时拿起左手边的筷子,这时每个人都只有一个筷子,这时需要拿起第二个筷子时发现没有筷子了就需要等待.但是所有人都在等待,就没人吃到面放下筷子.这就是循环等待.
解决方法
在分析解决方法钱我们需要知道发生死锁有4个必要条件:
1. 互斥性: 一个线程使用锁,另一个就需要等待.
2. 不可抢占: 一个线程在使用锁时,另一个线程不能强行抢占,只能等它自动解锁.
3. 请求保持: 一个线程持有一把锁的前提下,尝试获取另一把锁.
4. 循环等待
发生死锁,这4个条件缺一不可.
知道了发生死锁的条件后,我们就可以对症下药.我们只需要破坏其中一个条件就可以解除死锁.
1和2是锁的基本特性,我们不能改变.3我们需要看情况而定,有的情况可以避免,有的情况不可以避免.
4是最容易改变的.我们可以制定规则: 指定获取锁的顺序,为每个锁编号,先获取小的锁,再获取大的锁.这样就不会发生循环等待了.
改变锁的循环等待有多种方式:
-
增加一把锁
-
减少一个线程.
-
引入计数器,限制最多多少个线程同时获取锁
4.制定加锁顺序规则(最常用)
- 银行家算法
线程的通知等待 - wait和notify
这里是通过引入wait与notify来在应用层面来改变线程执行的先后顺序.
操作系统中线程在内核中的调度是抢占式,随机调度的,这是不可改变的.这里我们就是在应用代码层面来让线程主动放弃CPU的调度,从而影响到线程执行的先后顺序. 也就是让执行条件没达到的线程先放弃CPU的竞争,让其他线程先执行,等到条件达到时再参与竞争.
这里举个栗子:
多个滑稽老哥去ATM上执行一些操作
没有wait和notify时: 1号老哥进去取钱,发现没有钱了,那他就会出来与其他滑稽老哥进行竞争进入ATM的机会,1号老哥可能又会竞争到,再进去取钱发现没有钱,又出来和它们竞争,这样可能会多次1号滑稽进去但又没有进行到有用的操作.
转换成代码:
java
while(true) {
synchronized(....) {
if(ATM有钱) {
//取钱操作
}else {
//什么也不做
}
}
}
**有wait和notify时:**1号老哥进去取钱,发现没有钱了.那他会出来先不参与和它们老哥竞争进去的机会,而是等待其他老哥把钱存进去后再参与竞争,这样就减少了无效操作.
代码:
java
while(true) {
synchronized(....) {
if(ATM有钱) {
//取钱操作
}else {
wait();
}
}
}
画图理解:
对于上面第一种情况还是比较容易发生的, 1号滑稽拿到锁,处于RUNNABLE状态,其他线程处于WAITING 状态. 当1号滑稽解锁后再次竞争时,其他滑稽需要系统先唤醒在竞争,而1号滑稽不用唤醒可以直接竞争.
wait方法
对于wait方法,它的内部会做三件事情:
1. 解锁.
2. 进入堵塞状态.
3. 等到其他线程执行到notify方法时,解除堵塞,加入到锁竞争中.
wait的使用
1. wait需要在synchronized内部使用,不然会抛出异常.
2. wait的对象需要和synchronized的对象时一致的.
java
public class ThreadDemo1 {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
synchronized(object) {
System.out.println("执行wait前");
object.wait();
System.out.println("执行wait后");
}
}
}
这时我们可以打开jcons观察:
通过这里我们观察到main线程在wait这里进入了堵塞状态.需要解除堵塞我们就需要使用notify方法.
notify方法
notify方法就是用来解除wait造成的堵.
notify是不用在synchronized中使用的.比如在操作系统中也有wait和notify,notify是不用先加锁再使用的.但在Java中notify需要在synchronized中使用,不然会报错.
java
public class TreadDemo2 {
public static void main(String[] args) {
Object object = new Object();
Thread t1 = new Thread(() -> {
synchronized (object) {
System.out.println("wait方法前");
try {
object.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("wait方法后");
}
});
Thread t2 = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (object) {
System.out.println("notify方法前");
object.notify();
System.out.println("notify方法后");
}
});
t1.start();
t2.start();
}
}
代码执行过程:
1) t1和t2启动,并发执行.
2) t1拿到锁会进入阻塞等待
3) t2会先休眠一秒,这会让t1先拿到锁
4) 等到t2休眠结束, t1已经进入wait,将锁解除了,这时t2就可以拿到锁
5) 等到t2执行到notify时,t1的堵塞等待结束重新进入到锁竞争中.
6) 虽然t1等待结束但是t2还没释放锁,再等待t2释放锁后,t1才能拿到锁继续执行.
注意
- wait方法有三个可以使用:
第一个是死等,这个对代码非常的不利,一但忘记使用notify,线程就会卡死.我们一般常用的是第二种,有时间限制的等待,超过这个时间就不等了.第三个是微秒级的等待.
-
wait和notify的所对象得是一致的,不然会导致wait的堵塞等待解除不了.
-
notifyAll方法可以解除在多个线程使用同一个锁的wait的堵塞,但是这样不利于代码控制,我们还是比较推荐使用notify.
wait和sleep的区别
相同点:
-
sleep是指定时间的,wait也有指定时间的版本.
-
sleep和wait都可以提前唤醒. sleep是interrupt方法,wait是notify方法.
不同点:
-
sleep是在知道要休眠多久的情况下使用,wait是在不知道要等待多久的情况下使用
-
wait需要在synchronized中使用,sleep不需要.
-
wait是Object方法. sleep是Thread的静态方法.
Java标准库中的线程安全类
线程不安全类
ArrayList
LinkedList
HashMap
TreeMap
HashSet
TreeSet
StringBuilder
线程安全类
Vector
HashTable
ConcurrentHashMap
StringBuffer
String
这里前四个为线程安全主要是加了synchronizednized关键字不过这几个类jdk都快弃用了.
String为线程安全是因为它不可改变,就不涉及到线程安全问题了.