文章目录
- 💐线程不安全的示例
- 💐锁的特性
- 💐产生线程不安全的原因:
- 💐加锁的三种方式
💐线程不安全的示例
对于线程安全问题,这里用一个例子进行讲解👇:
我现在定义一个变量count, 使用两个线程对这个count进行自增,每一个线程中,count都自增10000, 那么,两个线程执行结束之后,最后的count应该是20000,下面验证一下:
public class Demo1 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
for(int i = 0; i < 10000; i++) {
//count自增10000次
count++;
}
});
Thread thread2 = new Thread(() -> {
for(int i = 0; i < 10000; i++){
//count自增10000次
count++;
}
});
thread1.start();
thread2.start();
//让主线程阻塞等待
thread1.join();
thread2.join();
//两个线程执行完毕后,打印最后结果
System.out.println("count:"+count);
}
}
第一次打印:
第二次打印:
第三次打印:
通过以上代码的得到,最后的结果与我们预期的结果完全不一样,而且,每次运行都会产生不一样的结果;以上代码如果放在一个线程中去运行,那是没有任何问题的,放在多线程中就出现了问题,以上情况就是一个非常典型的线程安全问题;
那么我们修改一下代码:
将thread1的join放在thread1的start后面,最后运行的结果就是对的!!!
这个 “同时执行” 是很关键的,既然同时执行时会产生线程不安全问题,那么有没有办法解决这样的问题呢?答案是:有的
产生问题的愿意:
首先,count++ 这个操作,站在CPU的角度上是由三步操作完成的:
load操作,把数据从内存中拿出来放到寄存器中
add操作,把count的值进行+1
save操作,把寄存器中的值放到内存中
以两个线程为例,因为线程是随机调度执行的,所以这三步操作就有以下的操作顺序:
上述,第一和第二种情况产生的结果就是正确的,下面通过画图演示一下以上这三步在CPU核心上的操作步骤,观察以下count是如何完成自增操作的;
假设,thread1 和 thread2这两个线程是在两个CPU核心上执行的,当然,在一个CPU核心上也是没有问题的,因为会有对应的上下文进行记录,为了好理解,下面我们假设在两个CPU核心上:
交叉执行的情况就会产生bug👇
由于在这20000次中,我们不知道有多少次是按照正确的方式自增的,又有多少次按照错误的方式自增的,所以最后就会产生一个小于20000的随机值;对于上述情况,就可以使用 **“加锁操作“**👇
💐锁的特性
在Java中,对线程加锁主要就是使用 synchronized
synchronized在使用时,需要修饰一个代码块,例如以下:
对上述自增例子的代码进行加锁:
public class Demo1 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object obj = new Object();//创建一个对象作为锁对象
Thread thread1 = new Thread(() -> {
synchronized (obj) {
//对线程进行加锁
for(int i = 0; i < 10000; i++) {
count++;
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (obj) {
for(int i = 0; i < 10000; i++){
//count自增10000次
count++;
}
}
});
//此时两个线程同时执行,就会同时获取锁对象,就会产生竞争
thread1.start();
thread2.start();
//两个线程执行完毕后,打印最后结果
System.out.println("count:"+count);
}
}
锁的特性主要分为两点:
(1)互斥特性
- 进入synchronized修饰的代码块,相当于加锁
- 退出synchronized修饰的代码块,相当于解锁
一个线程在加锁情况下,另一个线程也想要进行加锁,这样就会产生“锁冲突/锁竞争”,获得锁的线程就会继续执行,没有获得锁的线程就会阻塞等待,直到得到锁的线程释放锁为止;
如何理解阻塞等待?
例如这样一个场景,我们在排队上厕所时,当张三进入厕所后,就会将们锁住,这时候,李四和王五就只能在原地等待,等到张三出来以后,李四和王五才能去上厕所;
注意:
1.张三出厕所以后,李四和王五并不会按照顺序上厕所,而是会进行一个抢厕所,这就相当于,虽然现在有多个线程,但是在获取锁时,并不会按照先来后到的顺序获取,而是会进行争抢
2.上一个线程释放锁之后,下一个线程并不是直接就可以获取锁,而是需要操作系统进行一个“唤醒”的过程,这个过程是操作系统线程调度的工作部分
(2)刷新内存
synchronized的工作过程:
1.尝试获得锁
2.从主内存拷贝变量最新内容到工作内存
3.执行代码
4.将更改后的变量刷新到主内存中
5.释放锁
所以,synchronized也可以保证内存可见性
💐产生线程不安全的原因:
1.线程的调度顺序是随机的,是抢占式执行的
2.两个线程针对同一个变量进行修改
1.两个线程针对两个不同的变量进行修改
2.一个线程针对一个变量进行修改
3.两个线程针对同一个线程进行读取
以上改正后的三点就不会发生不安全问题;
3.修改操作不是原子的
什么是原子性:就像最开始讲的 load,add,save 指令,它们是三个指令,需要一个一个执行,所以它们不是原子的,如果它们三个指令发生的动作能够一个指令就完成,或者说在它们三个指令执行的过程中,不会被其他的指令进行穿插,这就是原子的。
4.内存可见性问题
5.指令重排序问题
那么,如何将不安全改成安全的呢?
首先,第一条原因是没办法修改的,因为线程的抢占式执行是由操作系统决定的,我们是无法修改的,第二条原因的话,针对有些代码,可能可以经过代码的调整来避免,但是,有些代码可能无法调整,就比如我们写的这个,我们的目的就是让count在两个线程中进行自增,所以第二条原因需要针对业务的情况,第三条原因就可以进行修改,将不是原子的操作改为原子的,通过加锁就可以解决上述非原子性问题,内存可见性和指令重排序问题也可以通过 Volaite 来解决。
💐加锁的三种方式
1.synchronozed修饰代码块👇
Object obj = new Object();
Thread thread1 = new Thread(() -> {
synchronized (obj) {
//对代码进行加锁
for(int i = 0; i < 10000; i++) {
count++;
}
}
});
**2.修饰实例方法👇:**哪个对象调用increase,哪个就是 “锁对象”
class Counter{
public static int count;
//谁调用这个方法,谁就是锁对象
//写法一:
synchronized public void increase() {
count++;
}
//写法二:
public void increse() {
synchronized(this) {
count++;
}
}
}
public class Demo1 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread thread1 = new Thread(() -> {
for(int i = 0; i < 10000; i++) {
counter.increase();
}
});
thread1.start();
System.out.println(Counter.count);
}
}
3.修饰静态方法👇
如果是修饰静态方法,相当于是对“类对象”进行了加锁;
class Counter{
public static int count;
//写法一:
synchronized public static void increase1() {
count++;
}
//写法二
public static void increase2() {
//对类对象加锁
synchronized (Counter.class) {
count++;
}
}
}