文章目录
- 1..多线程的安全
- 1.1出现多线程不安全的原因
- 1.2解决多线程不安全的⽅法
- 1.3三种典型死锁场景
- 1.4如何避免死锁问题
- 2.线程等待通知机制
- 2.1等待通知的作用
- 2.2等待通知的方法——wait
- 2.3唤醒wait的方法——notify
1…多线程的安全
1.1出现多线程不安全的原因
- 线程在系统中是随机调度,抢占式执⾏。
- 多个线程同时修改同⼀个变量
- 线程对变量的修改操作不是“原⼦”的
- 内存可⻅性问题
- 指令重排序、
“原⼦”是什么?
原⼦是指不可再拆分的最⼩单位,放到代码操作中就是⼀段代码对应⼀个cpu指令就是原⼦的,如果对应到多个cpu指令就不是原⼦的。
1.2解决多线程不安全的⽅法
从原因⼊⼿:
- 原因1由于是系统本⾝的原因⼈为⽆法⼲预。
- 原因2是⼀个解决多线程不安全的⽅法,但是只是在特定的场景下才可以实现。
- 原因3是解决多线程不安全最合适的⽅法,既然修改的这个操作不是⼀个原⼦,那我们只需要将这
个不是“原⼦”的操作打包成⼀个原⼦的操作即可。
引申出⼀个新的词“锁”
我们可以通过锁来将之前不是原⼦操作打包成⼀个原⼦操作
关于这个锁本⾝就是系统内核中的api,只不过是jvm将这个api封装了,为了让java可以更好的使⽤这
个锁。
关于锁主要操作的两个⽅⾯:
1.加锁
⽐如对t1线程进⾏加锁,t2线程也要加锁,那么t2线程就会阻塞等待(互斥锁/竞争锁/锁冲突)
2.解锁
t1线程解锁之后,t2线程才可以进⾏加锁
注意:此处的锁主要针对于锁对象,对于t1和t2线程是指的同⼀个锁对象,不是同⼀个锁对象那就没
意义了。
对锁的总结:
1.两个操作⸺加锁,解锁
2.锁的特性⸺互斥
3.只有多个线程竞争同⼀把锁才会互斥,如果多个线程竞争不同的锁则不会产⽣互斥
java中⽤⼀个关键字来描述锁⸺synchronized
1.synchronized 怎么读?怎么拼写?
synchronized
2.synchronized()括号中写的是锁对象⸺锁对象可以是任意类实列出的对象
注意:
锁对象的⽤途只有⼀个,两个线程是否针对⼀个对象加锁
如果是就会出现锁竞争/锁冲突/互斥,就会引起阻塞等待
如果不是就不会出现锁竞争/锁冲突/互斥,就不会引起阻塞等待
和对象具体是什么类型,和它内部有什么属性,有什么⽅法,接下来是否要操作这个对象,统统都没
有关系
3.synchronized下⾯跟着{}
当进⼊到这个代码块就是对上述(锁对象)进⾏了加锁操作
当出了代码块就是对上述(锁对象)进⾏了解锁操作
以下代码就是两个线程针对同⼀个对象locker加锁,当t1线程加锁之后,t2想加锁只能阻塞等待,只有
等到t1线程解锁之后,t2线程才有可能加锁成功
public static int count = 0;
//创建一个对象作为一个锁对象
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(()-> {
for (int i = 1; i <= 5000; i++) {
synchronized(locker) {
count++;
}
}
});
Thread t2 = new Thread(()-> {
for (int i = 1; i <=5000 ; i++) {
synchronized(locker) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = "+ count);
}
以下代码就是两个线程对不同锁对象进行加锁,就不会产生锁冲突/锁竞争/互斥。
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() -> {
for (int i = 1; i <=5000 ; i++) {
synchronized(locker1) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 1; i <=5000 ; i++) {
synchronized(locker2) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
当t1线程解锁之后,不一定是t2线程拿到锁,有可能是其他线程拿到锁。
join与锁的区别:
join是一个线程完了,再让第二个线程执行。
锁只是针对加锁的那一块代码,就像上述代码中加锁的count++就会变成串行执行,但剩余的代码还是并发执行。
注意:加锁不是针对线程,而是针对共享资源的访问操作,比如现在我对t1线程中的操作1进行了加锁,但是系统内核将t1线程调度走了,可以让其他线程调度到t1线程的位置继续执行操作1,此时t2线程还是无法加到锁.
另外加锁的方式:
1.写一个方法将加锁的关键字放在方法中:
public static int count = 0;
synchronized public void add() {
count++;
}
public static void main(String[] args) throws InterruptedException {
Deom14 deom14 = new Deom14();
Thread t1 = new Thread(()-> {
for (int i = 1; i <= 5000; i++) {
deom14.add();
}
});
Thread t2 = new Thread(()-> {
for (int i = 1; i <=5000 ; i++) {
deom14.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = "+ count);
}
对于这种方式加锁还有一种写法:
public static int count = 0;
public void add() {
synchronized (this){
count++;
}
}
public static void main(String[] args) throws InterruptedException {
Deom14 deom14 = new Deom14();
Thread t1 = new Thread(()-> {
for (int i = 1; i <= 5000; i++) {
deom14.add();
}
});
Thread t2 = new Thread(()-> {
for (int i = 1; i <=5000 ; i++) {
deom14.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = "+ count);
}
利用this,谁调用了这个方法就用这个对象作为锁对象,
2.synchronized对static方法进行加锁,相当于对类的类对象进行加锁
public static int count = 0;
synchronized static void func() {
count++;
}
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(()-> {
for (int i = 1; i <= 5000; i++) {
Deom15.func();
}
});
Thread t2 = new Thread(()-> {
for (int i = 1; i <=5000 ; i++) {
Deom15.func();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = "+ count);
}
对于这种写法有个致命的缺点,一旦有多个线程调用func,则这些线程都会触发锁竞争。
4. 原因4引起的线程不安全——编译器优化产生的线程不安全
public static int count;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while(count == 0) {
//未执行操作
}
System.out.println("退出t1线程");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个数:");
count = scanner.nextInt();
});
t1.start();
t2.start();
}
在t1线程中while(count == 0)这个操作在指令的角度来分析就两个指令分别为load和cmp指令
load指令将内存中数据读入cpu中寄存器
cmp在cpu寄存器中进行比较
1.内存读取数据的速度远远小于寄存器读取的数据的速度就会造成load指令执行的速度远远慢于cmp指令执行。
2.在t2线程未修改count之前load指令执行的结果是一样的。
由上述两个原因,java编译器为了提高效率就会将load指令这个操作优化,所以当t2线程修改了count的值t1线程也不会感知到。
如果在t1线程的循环体中加一些I/O操作或者阻塞操作,这样java编译器就不会去优化load指令。
public static int count;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while(count == 0) {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("退出t1线程");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个数:");
count = scanner.nextInt();
});
t1.start();
t2.start();
}
如何解决由编译器优化引起的线程不安全?
1.上述在循环体中加I/O操作或者阻塞操作可以解决
2.用volatile关键字来修饰需要修改的变量——这个关键字只能解决编译器优化带来的内存可见性问题,不能解决原因三带来的问题。
volatile关键字
当为count加上volatile关键字就会告知编译器这个变量不能随便被优化`在这里插入代码片
public volatile static int count;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while(count == 0) {
}
System.out.println("退出t1线程");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个数:");
count = scanner.nextInt();
});
t1.start();
t2.start();
}
- 原因5引起的线程不安全——编译器优化策略导致
指令重排序旨在编译器在优化的时候将你的代码重新调整执行顺序来提高效率,优化前的逻辑与优化后的逻辑是等价的,在单线程中指令重排序这个优化策略是不会造成线程不安全,但是在多线程中就会导致线程不安全。
面对指令重排序我们采取用volatile关键字
class SinlgetonLazy1 {
private static volatile SinlgetonLazy1 instance = null;
public static SinlgetonLazy1 getInstance() {
Object locker = new Object();
if(instance == null) {
synchronized(locker) {
if (instance == null ) {
instance = new SinlgetonLazy1();
}
}
}
return instance;
}
private SinlgetonLazy1() {
}
}
public class Deom24 {
public static void main(String[] args) throws InterruptedException {
SinlgetonLazy1 s = SinlgetonLazy1.getInstance();
Thread t1 = new Thread(() -> {
SinlgetonLazy1 s1 = SinlgetonLazy1.getInstance();
});
Thread t2 = new Thread(() -> {
SinlgetonLazy1 s2 = SinlgetonLazy1.getInstance();
});
t1.start();
t2.start();
}
}
1.3三种典型死锁场景
场景一:锁是不可重入锁,并且一个线程针对一个锁对象,连续被加锁两次。
采取可重入锁(synchronized)就可以对这个问题迎刃而解了
场景二:两个线程两把锁
现有t1线程t2线程和locker1锁locker2锁, locker2锁要对t1线程中内容加锁,但同时locker2锁对t2线程还未解锁,所以t1线程需要阻塞等待,而现在locker1锁也要对t2线程中的内容加锁,但是locker1对t1线程还未解锁,所以t2线程需要阻塞等待,这样就导致你等我,我等你的死锁现象`
public static void main(String[] args) {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker2) {
System.out.println("t1线程获取到了两把锁");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (locker2) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker1) {
System.out.println("t2线程获取到了两把锁");
}
}
});
t1.start();
t2.start();
}
上述代码就是场景二死锁的实现代码
通过jconsole窗口观察:
此时就是t1线程想要获取到locker2锁,但locker2锁被t2线程给加锁了并且没有解锁,所以此时的t1线程想要获取locker2锁只能阻塞等待。
此时就是t2线程想要获取到locker1锁,但locker1锁被t1线程给加锁了并且没有解锁,所以此时的t2线程想要获取locker1锁只能阻塞等待。
场景三:N个线程M把锁——哲学家就餐问题
约定每一个哲学家必须先获取编号小的筷子,后获取编号大的筷子,就可以解决哲学家就餐问题。
1.4如何避免死锁问题
出现死锁的四大必要条件,少一个都不会出现死锁。
- 锁具有互斥特性(基本特点,一个线程拿到锁之后,其他线程就得阻塞等待)——基本特点
- 锁不可抢占(不可被剥夺)——基本特点
- 请求和保持(一个线程拿到一把锁之后,不释放这个锁的前提下,再尝试获取其他锁)——代码结构
- 循环等待(多个线程获取多个锁的过程中,出现了循环等待,A线程等待B线程,B线程又等待A线程)——代码结构
要避免死锁,由于第一和第二点是锁的基本特性所以我们无法避免,我们只能从第三点和第四点出发避免死锁。
针对第三点:我们尽量不要出现嵌套锁。
针对第四点:我们可以约定加锁的顺序,让所有的线程按照加锁的顺序来获取锁。
针对上述的代码出现了死锁,我们就可以约定加锁的先后顺序来避免死锁,我们约定locker1先加锁,locker2后加锁。
public static void main(String[] args) {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker2) {
System.out.println("t1线程获取到了两把锁");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (locker1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker2) {
System.out.println("t2线程获取到了两把锁");
}
}
});
t1.start();
t2.start();
}
2.线程等待通知机制
2.1等待通知的作用
通过条件,判断当前逻辑是否能够执行,如果不满足条件不能执行,那么就主动进行阻塞(wait)让其他线程来调度cpu的资源,等到条件满足的时候,再让其它线程(阻塞的线程)来唤醒。
2.2等待通知的方法——wait
- wait方法是Object类提供,所以任何对象都能调用这个方法
- wait方法和sleep一样会被interrupt打断并且自动清空标志位
- wait方法不仅仅可一个阻塞等待还可以解锁
- wait方法要放在synchronized内部使用
public static void main(String[] args) {
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker) {
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
2.3唤醒wait的方法——notify
- notify方法要放在synchronized内部使用
- notify方法是Object类提供,所以任何对象都能调用这个方法wait方法是Object类提供,所以任何对象都能调用这个方法
- notify是随机唤醒被阻塞的线程
- notifyAll()方法可以唤醒所有被阻塞的线程,但这种方法不常用。`
public static void main(String[] args) {
Object locker = new Object();
Thread t1 = new Thread(() -> {
System.out.println("t1线程等待之前");
synchronized (locker) {
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("t1线程等待结束");
});
Thread t2 = new Thread(() -> {
System.out.println("t2线程唤醒t1线程之前");
synchronized (locker) {
try {
Thread.sleep(3000);
locker.notify();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("t2线程唤醒结束");
});
t1.start();
t2.start();
}
此时的t1线程的状态转化为:WAITTING——RUNNABLE——BLOCKED