线程安全问题解决
1. synchronized的关键字
1.1 synchronized的特性
1) 互斥
synchronized会起到互斥的效果,当某个线程执行到某个对象的synchronized时,如果其他线程也执行到同一个对象的synchronized就会陷入阻塞等待.synchronized使用的锁是存在于对象里的.
- 进入synchronized修饰的代码块,相当于加锁
- 退出synchronized修饰的代码块,相当于解锁
使用synchronized的时候,其实是对某个具体的对象进行加锁,当synchronized直接修饰方法的时候,就相当于针对this(count对象)加锁.
如果两个线程针对同一个对象进行加锁,就会出现锁竞争/锁冲突(一个线程加锁成功,另外一个线程阻塞等待).如果是两个线程针对不同对象进行加锁,那么就不会产生产生所冲突,也就不存在阻塞等待.但也不会让两个线程按照串行的方式调度,任然会存在线程安全问题.
class Counter {
public int count = 0;
public Object locker = new Object();
//lambda通过变量捕获进行对count进行修改,如果是局部变量,则不能进行局部变量修改
public void increase1() {
//两个线程针对针对不同对象加锁,不会阻塞等待,不存在锁竞争,也就会导致线程安全问题
// synchronized (this) {
// count++;
// }
synchronized (locker) {
count++;
}
}
public void increase2() {
synchronized (locker) {
count++;
}
}
}
public class demo3 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter.increase1();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter.increase2();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
//只有针对同一个变量加锁,会出现阻塞等待,才会解决线程安全问题,如果一个线程加锁,另外一个线程没有加锁,那么也会出现线程安全问题
synchronized用的锁其实是存在Java对象里的,可以粗略理解为当每个对象在内存中存储,都会有一块内存表示当前"锁"的状态,比如厕所里的门锁,当是无人的状态下,那么就可以进入,进入后需要把门锁锁上,标志为"有人"状态.如果时"有人"状态下,其他人就必须等待.
阻塞等待:针对没一把锁,操作系统都维护了一个等待系列,当这个锁被某个线程占用的时候,其他线程尝试加锁,那么就会加锁失败,陷入阻塞等待,一直由上一个线程解锁之后,由操作系统唤醒的线程,再来获取到这个锁
- 上一个线程解锁之后,下一个线程并不是立马就能获取到锁,而是要靠操作系统"唤醒"."唤醒"也是操作系统线程调度的一部分工作.
- 假如由A B C三个线程,线程A先拿到锁,然后B获取锁,然后C获取锁,那么B和C都在阻塞队列中等待,当A释放锁之后,虽然说B比先来,但是B不一定就能获取到锁,而是和C重新竞争,并不遵守先来后到的规则.
2) 刷新内存
synchronized的工作流程:
- 线程进入synchronized代码块或方法,获取锁
- 每个线程都会有自己的线程栈,线程在执行的时候,会将变量从主内存中复制到自己的线程栈中
- 执行代码
- 将修改后的共享数据刷新到主内存中,为了保证多线程中数据一致性.
- 释放锁
3) 可重入
可重入锁指的是同一个线程在持有某把锁的情况下,再次请求该锁时可以继续获取,而不会因为已经持有锁而被阻塞.
不可重入锁
lock();
//第一次加锁加锁成功
lock()
;//第二次加锁,锁已经被占用,阻塞等待
当我们第二次进行加锁的时候,会阻塞等待,因为第一次的锁还没被释放掉,只有第一把锁释放掉后,才能获取到第二把锁,但是释放第一把锁也是有该线程完成,结果线程已经结束操作/停止操作,那么就会出现死锁,这种锁称之为不可重入锁
Java中的synchronized关键字就是可重入锁,当进入代码块是就会加锁,出了代码块就会自动解锁.
public class demo{
public static Object lock = new Object();
public static void main(String[] args) {
synchronizedMethod();
}
public static void synchronizedMethod() {
synchronized (lock) {
System.out.println("第一次进入");
}
//第二次进入,由于是同一个线程可继续获取锁,继续执行代码
synchronized (lock) {
System.out.println("第二次进入");
}
}
}
1.2 synchronized的使用方式
1 )修饰普通方法:锁的的是synchronized对象
public class demo{
public synchronized void method() {
}
}
2 )修饰静态方法:锁的demo类的对象,静态方法和具体的对象无关,是和类有关的(类方法)
class demo{
public synchronized static void method() {
}
}
锁类对象
public class demo{
public void method() {
synchronized (demo.class) {
}
}
}
3 )修饰代码块:明确指定锁哪个对象
public class demo{
public void method() {
synchronized (this) {
}
}
}
2. volatile关键字
2.1 内存可见性
程序在编译的时候,Java编译器和jvm可能会对代码做出"优化",当代码实际程序执行的时候,编译器/jvm就可能会更改代码,但会保持原有逻辑不变的情况下,提高代码执行效率.编译器优化,本质上靠代码,智能的对你写的代码进行分析判断,进行调整.但如果是多线程,此时的优化就可能会出现差错,是程序原有的逻辑发生改变.
//创建两个线程t1和t2
//t1始终在进行while循环,以flag==0作为循环条件
//t2让用户通过控制台输入一个整数,作为isQuit的值
//预期当用户输入非0时,t1线程结束
public class demo {
public static int isQuit = 0;
public static void main(String[] args) {
//t1读取的是自己弘佐内存中的内容.
Thread t1 = new Thread(() -> {
while (isQuit == 0) {
;
}
System.out.println("t1线程结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入isQuit的值:");
isQuit = scanner.nextInt();
//在内存中修改了isQuit的值,但是t1线程没有重复读取isQuit的值,
//t1线程没有感知到t2线程的修改
});
t1.start();
t2.start();
}
}
//当输入非0值,已经修改了isQuit的值,但是t1线程并没有结束(bug)
在while(isQuit == 0)
中本质上是两条指令:1. load(读内存):速度极慢 2.jmp(比较并跳转:程序成立就进入代码块):寄存器操作,速度极快.对于jvm来说,在这个逻辑中,代码快速的读取同一个内存中的值,并且内存中的值每次读取还都是一样的,编译器就会把load操作优化掉,只是第一次执行了load,后续都不再执行,直接拿寄存器中的值行进比较.但是我们在另外一个线程中修改了isQuit中的值,编译器就没办法准确判定t2到底会不会执行/什么时候执行,因此就会导致出错.
使用volatile关键字用来修饰一个变量后,编译器就会明白,这个变量是"易变的",就不会把读操作优化到读寄存器中,于是就可以保证t1在循环的过程中,始终都能读取到内存中的数据了.
//给isQuit加上volatile
public class demo1 {
public volatile static int isQuit = 0;
}
volatile本质上是保证变量的内存可见性(禁止该变量的读操作被优化到读寄存器中).当一个线程修改了共享变量时,其他线程就会立即读取到这个修改的值.
volatile和synchronized的本质区别就是:synchronized能够保证原子性,volatile保证的是内存可见性.不能保证原子性.
3.wait和notify
在我们前面所了解多线程的执行过程中,知道多线程之间的调度是随机的,我们希望多个线程能够按照我们规定的顺序来执行,完成线程之间的配合工作.其中wait和notify就是用来协调线程调度顺序的工具.
3.1 wait()方法
wait方法的主要作用是使线程进入等待状态,并释放该线程持有的锁,调用wait方法会有以下几个步骤
- 当线程执行到wait方法时,首先会检查是否持有了当前对象的锁(即synchronized关键字所保护的锁),如果没有锁就会抛出IllegalMonitorStateException异常
- 如果持有锁,那么调用wait方法后,该线程就会立即释放该锁,其他线程可以获取这个锁并继续执行.
- 被等待的线程进入等待状态,直到其他线程调用了同一个对象的notify或notifyAll方法.
- 等待的线程被唤醒后,会重新尝试获取之前的锁,一旦获取成功,就可以继续执行.
wait是Object的方法,当wait引起线程阻塞后,可以使用interrupt方法,把线程给唤醒,打断当前的阻塞状态
public class demo2 {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
synchronized (object) {
//wait是先解锁,所以要先加上锁
object.wait();
}
System.out.println("wait结束");
}
}
//此处wait进行,但并没有执行到"wait结束",说明该线程已经阻塞成功,一直阻塞到其他线程进行notify和notifyAll方法.
3.2 notify()方法
notify是一个线程同步方法,用于唤醒等待在同一个对象上等待的线程.调用notify方法会选择一个处于等待状态的线程,通知他继续执行,notify只能唤醒一个线程,如果有多个线程等待,只会唤醒其中的一个,具体唤醒的线程是哪一个不确定.
举个栗子:假如有一家餐厅,有厨师和多个服务员多个线程(厨师表示正在执行的线程),厨师负责烹饪菜品,服务员负责上菜.厨师在厨房中做好了一道菜,然后调用wait()方法进入等待状态,表示没有菜品可以上菜了,这个时候,服务员进入厨房,然后调用notify()方法来将厨师唤醒,表示有新的菜品的需要上菜,厨师就会被唤醒继续烹饪.
class Restaurant {
private static Object food = new Object();
public void sever() throws InterruptedException {
synchronized (food) {
System.out.println("服务员:菜品准备好,等待上菜");
food.wait();//服务员等待上菜
System.out.println("服务员:上菜完成");
}
}
public void cook() throws InterruptedException {
synchronized (food) {
Thread.sleep(4000);//假设烹饪需要4秒
System.out.println("厨师:菜品已做好,通知服务员上菜");
food.notify();//厨师通知服务员上菜
}
}
}
public class demo3 {
public static void main(String[] args) {
Restaurant restaurant = new Restaurant();
Thread t1 = new Thread(() -> {
try {
restaurant.cook();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread t2 = new Thread(() -> {
try {
restaurant.sever();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t2.start();
t1.start();
}
}
wait()和notify()方法注意事项
- 如果有多个线程等待,那么线程调度器会随机挑选出一个wait状态的线程(没有"先来后到")
- 使用notify方法,当前线程不会立马去唤醒等待的线程,而是要等到执行完notify()方法的线程执行完后,再去执行被唤醒的线程.
- notify和wait必须是使用同一个对象调用的才能让notify能够顺利的唤醒wait
- 虽然notify不涉及解锁操作,但wait和notify都必须要放到synchronized之内,
- 如果进行notify的时候,另外一个线程没有处于wait状态,此时notify就相当于"空打一炮",没有任何副作用.
3.3 notifyAll()方法
notify方法只是唤醒某一个等待线程,使用notifyAll方法就可以一次性唤醒所有等待的线程.由于notifyAll会唤醒所有的线程会导致不必要的竞争和资源浪费.
如果我们使用notifyAll,但是想要按照执行的线程执行,那么就可以让不同的线程,使用不同的对象进行wait.想唤醒谁,就可以使用对应的对象来notify.
//按照顺序打印ABC
class Print{
public int flag = 1;//用于标识当前字符
public void print1() throws InterruptedException {
synchronized (this) {
//防止虚假唤醒,虚假唤醒:没有收到notify和notifyAll信号的情况下从等待状态返回,没有明确的唤醒信号,使用while判断等待条件
while (flag!=1) {
wait();//与打印字符不符合陷入阻塞等待
}
System.out.print('A');
flag = 2;
notifyAll();//唤醒所有线程,重新检测flag的值
}
}
public void print2() throws InterruptedException {
synchronized (this) {
while (flag!=2) {
wait();
}
System.out.print('B');
flag = 3;
notifyAll();
}
}
public void print3() throws InterruptedException {
synchronized (this) {
while (flag!=3) {
wait();
}
System.out.println('C');
flag = 1;
notifyAll();
}
}
}
public class demo5 {
public static void main(String[] args) throws InterruptedException {
Print print = new Print();
Thread t1 = new Thread(() -> {
try {
print.print1();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
Thread t2 = new Thread(() -> {
try {
print.print2();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
Thread t3 = new Thread(() -> {
try {
print.print3();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
}
}
3.4 wait和sleep的对比
- wait()方法是Object类的方法,可以在任意对象上调用,用来将当前线程置于等待状态.直到有其他线程调用相同对象唤醒.
sleep()方法是Thread类静态方法,通过线程本身调用,用来将当前线程休眠一段时间,不需要其他线程干涉就能自动恢复.- wait()方法必须在同步代码块(synchronized)或同步方法中使用.
sleep()方法可以在任意代码块中使用,不需要获取锁.- wait()方法用来控制线程之间的协调
sleep()方法用来控制线程之间的休眠- wait()方法在等待期间收到中断异常会被唤醒,抛出InterruptedException 异常
sleep()方法在等待期间收到中断异常,需要将中断标志位设置为true,不会抛出异常,需要手动检查中断状态并处理.