由于线程调度顺序是无序的,则让两个线程对同一个变量各自自增5w次,看变量的运行结果。
为啥会出现线程安全问题?
本质原因:线程在系统中的调度是无序的/随机的(抢占式执行)。
public class TestDemo {
public static int x;
public static void main(String[] args) throws InterruptedException {
//两个线程对同一个变量各自自增5w次,看变量的运行结果
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
x++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
x++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(x);
}
}
//另一种写法
class Counter {
public int count = 0;
public void add() {
count++;
}
public int getCount() {
return count;
}
}
public class TestDemo2 {
public static void main(String[] args) throws InterruptedException {
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();
t1.join();
t2.join();
System.out.println(counter.getCount());
//实际结果和预期结果不相符,由多线程引起的bug【与线程的调度随机性密切相关】
}
}
分析:有多少次是“顺序执行”,有多少次是“交错执行”是不知道的,得到的结果是啥也是变化的。
count++操作,本质上是3个cpu指令构成的(不是原子的)
- load把内存中的数据读取到cpu寄存器中
- add把寄存器中的值进行+1运算
- save把寄存器中的值写回到内存中
归根结底,线程安全问题全是因为线程的无序调度导致了执行顺序不确定,结果就变化了。
①抢占式执行,随机调度(罪魁祸首,万恶之源)
线程中的代码执行到任意一行,都随时可能被切换出去
②多个线程同时修改同一个变量
注:一个线程修改同一个变量(安全)
多个线程读取同一个变量(安全)
多个线程修改不同变量(安全)
③修改操作不是原子的
注:如果某个操作对应多个cpu指令,大概率不是原子的,正是因为不是原子的,导致两个线程的指令排序存在更多的变数了。
例如++操作就不是原子的。
④内存可见性
编译器执行代码的时候对代码进行优化,有些操作下频繁读取内存,但是读取的结果不变,则优化成只从寄存器去读,不从内存中读。在这种情况下,如果另外一个线程修改了内存的值,原来读的那个线程无法感知到。【一个线程频繁读,一个线程修改】
⑤指令重排序
编译器优化产生的线程不安全。
public void add() {
synchronized (this) {
count++;
}
}
①此处使用代码块的方式来表示,进入synchronized 修饰的代码块的时候,就会触发加锁,出synchronized 的代码块,就会触发解锁。
②synchronized (),括号()中表示锁对象,在针对哪个对象加锁,如果2个线程针对不同对象加锁,此时不会存在锁竞争,各自获取各自的锁即可;如果2个线程针对同一个对象加锁,此时就会出现“锁竞争”,一个线程先拿到了锁,另一个线程阻塞等待。
③()里的锁对象可以是写任意一个Object对象,基本数据类型不可以,此处写了this相当于counter对象。
例如:手动指定一个锁对象,相当于吉祥物,仅仅是起到了一个标识的效果。
private Object locker = new Object();
public void add() {
synchronized (locker) {
count++;
}
}
注:如果多个线程尝试对同一个锁对象加锁,就会产生锁竞争;针对不同对象加锁,就不会有锁竞争。
synchronized public void add() {
count++;
}
如果直接给方法使用synchronized修饰,此时就相当于以this为锁对象。
下图2种加锁方法等价。
3. 给静态方法加锁
public static void test() {
synchronized (Counter.class) {
}
}
synchronized public static void test1() {
}
如果synchronized修饰静态方法(static),此时就不是给this加锁了,而是给类对象加锁。
类对象是什么?
Counter.class
类对象相当于“对象的图纸”,描述了类的方方面面的详细信息。
类对象可以用来表示.class文件的内容。
import java.util.Scanner;
public class ThreadDemo1 {
public static int falg = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (falg == 0) {
}
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
falg = scanner.nextInt();
});
t1.start();
t2.start();
}
}
运行结果:
分析:理论上当输入2之后,线程1应该结束,实际上通过jconsole发现并没有结束。这是因为循环条件flag=0,编译器每次从内存中读数据发现都为真,因此编译器自动帮助我们优化了,编译器对于代码优化产生了误判。
内存可见性:就是多线程的环境下,编译器对于代码优化产生了误判,从而引起了bug,导致代码bug。
编译器优化:智能的调整代码执行逻辑,保证程序结果不变的前提下,通过加减语句、语句变换、一系列操作,让整个程序的执行效率大大提升。编译器对于“程序结果不变”单线程下判定是非常准确的,但是多线程就不一定了。
加了sleep循环执行速度就非常慢,当循环的次数下降了,此时load操作就不再是负担,编译器就不需要优化了。
Thread t1 = new Thread(() -> {
while (falg == 0) {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
volatile关键字:加上volatile关键字之后,编译器就能够保证每次都是重新从内存读取flag变量的值。
volatile public static int falg = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
falg = scanner.nextInt();
});
t1.start();
t2.start();
}
运行结果:
分析:此时t2修改falg,t1就可以立即感知到了,t1就可以正确退出循环。
volatile适用的场景:一个线程读,一个线程写
sychronized适用的场景:多个线程写
volatile的效果,称为“保证内存可见性”。
指令重排序也是编译器优化的策略,调整了代码执行的顺序,让程序更高效。
谈到优化,都得保证优化之后的结果和之前是不变的,在单线程的情况下容易保证,但是在多线程的情况下就不好说了。
class Student {
}
public class ThreadDemo2 {
public static Student s;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
s = new Student();
});
Thread t2 = new Thread(() -> {
if (s != null) {
System.out.println("111");
}
});
t1.start();
t2.start();
}
}
分析:s = new Student()
大体可以分为3步:①申请内存空间②调用构造方法,初始化内存的数据③把对象的引用赋值给s。②和③编译器优化可对其进行调换顺序,这样上述代码就可能会因为指令重排序出现问题。
解决办法:使用volatile关键字,volatile关键字的作用主要有如下2个:
- 保证内存可见性:当一个线程修改一个共享变量的时候,另一个线程能读到这个修改的值。
- 保证有序性:禁止指令重排序,编译时jvm编译器遵循内存屏障的约束,运行时靠屏障指令组织指令顺序。
注意:volatile不能保证原子性。
由于线程的调度是无序的、随机的,但是在一定的需求场景下,希望线程有序执行。
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
System.out.println("wait之前");
o.wait();
System.out.println("wait之后");
}
运行结果:
分析:IllegalMonitorStateException 非法锁状态异常,锁还没获取到,就尝试解锁,就会产生上述异常。【此时wait需要解锁,但是都没加上锁】
wait主要做3件事:①解锁 ②阻塞等待 ③当收到通知的时候唤醒,同时尝试重新获取锁。
因为wait必须写到synchronized代码块里面,这样才能尝试①解锁。
同理notify也是要放到synchronized中使用的。
先有wait再有notify,否则就相当于一炮打空了。
使用wait,阻塞等待会让线程进入WAITING状态。
synchronized (o) {
o.wait();
}
注意:加锁的对象必须和wait的对象是同一个,这样wait操作就是在针对当前对象进行解锁。
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
//wait
Thread t1 = new Thread(() -> {
System.out.println("wait之前");
synchronized (object) {
try {
object.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("wait之后");
});
t1.start();
Thread.sleep(1000);
//notify
Thread t2 = new Thread(() -> {
System.out.println("notify之前");
synchronized (object) {
object.notify();
}
System.out.println("notify之后");
});
t2.start();
}
运行结果:
分析:
t1先执行,执行到了wait,就阻塞了
1秒之后,t2开始执行,执行到了notify,就会通知t1线程唤醒。
注意:notify是再synchronized内部,就需要t2释放锁之后t1才能继续往下走,因为t1要重新获取锁,所有要t2释放锁。
join和wait、notify的区别
join只能让t2线程先执行完,再继续执行t1,一定是串行的。
wait notify可以让t2执行完一部分,再让t1执行,t1执行一部分,再让t2执行,非常灵活。
wait有一个带参数的版本,用来体现超时时间,这个时候感觉和sleep差不多。wait和sleep都能提前唤醒。
最大的区别:初心不同,设计这个东西解决的问题不同。
wait解决线程之间的顺序控制。
sleep解决让当前线程休眠一会。
使用上也有明显的区别:wait要搭配锁使用,sleep不需要。
再进一步,只是java这里的sleep和wait用法看起来比较像,其他语言的sleep和wait差别很大。