目录
在上篇博客中,我们学习了进程和线程的概念,以及线程的一些内容,这篇博客我们继续学习线程中的一个重点内容—线程安全,这也是线程里面最复杂的一部分。
1.概念
如果多线程环境下代码运⾏的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。
接下来,我们用代码直观感受一下线程不安全。(答案与预期不符合)
public class Demo13 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
//对count 进行自增
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
//对count 进行自增
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
//预期结果是10w
System.out.println(count); //但结果不唯一
}
}
我们可以直观看到答案与我们预期结果10w不相符,这就是一个典型的线程不安全。
经过这样更改了之后,虽然达到了预期结果10w,但也不是我们想要的,因为这样虽然是写在两个线程中了,但并不是“同时执行”,即在线程t1执行的时候,t2并不会启动。
2.产生原因
1.操作系统中,线程的调度顺序是随机的(抢占式执行),罪魁之首,万恶之源(系统内核里实现的,没办法进行修改)
2.两个线程,针对同一个变量,进行修改 (有些情况下可以通过调整代码结构,规避上述问题)
3.修改操作,不是原子的(此时的count++,就是非原子的操作,先读,再修改)(想办法让count++这里的三步走,成为原子的 - 加锁)
4.内存可见性问题
可⻅性指,⼀个线程对共享变量值的修改,能够及时地被其他线程看到。
5.指令重排序问题
是编译器的一种优化模式。
3.解决办法
加锁就可以解决,那么如何进行加锁呢?
其中最常通过使用synchronized关键字
synchronized修饰的是一个代码块,同时会指定一个“锁对象” -> 有且只有一个,当两个线程同时尝试对一个对象加锁,此时就会出现“锁冲突”/“锁竞争” 一旦竞争出现,一个线程能够拿到锁,继续执行代码;一个线程拿不到锁,就会阻塞等待,等待前一个线程释放锁之后,它才有机会拿到锁,
继续执行 -> 本质上把“并发执行”改成了“串行执行”
public class Demo13 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
//对count 进行自增
for (int i = 0; i < 50000; i++) {
synchronized (locker) { //加锁 ()中需要表示一个用来加锁的对象,这个对象是啥不重要,
// 重要的是通过这个对象来区分两个线程是否竞争同一个锁
count++;
}
}
});
Thread t2 = new Thread(() -> {
//对count 进行自增
for (int i = 0; i < 50000; i++) {
synchronized (locker) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
//预期结果是10w
System.out.println(count); //但结果不唯一
}
}
3.1 synchronized关键字
-监视器锁 monitor lock
3.1.1 特性
1.互斥
synchronized会起到互斥效果,个线程执⾏到某个对象的synchronized中时,其他线程如果也执⾏到同⼀个对象synchronized就会阻塞等待.
• 进⼊synchronized修饰的代码块,相当于加锁
• 退出synchronized修饰的代码块,相当于解锁
2.可重⼊
synchronized同步块对同⼀条线程来说是可重⼊的,不会出现⾃⼰把⾃⼰锁死的问题。
所谓的可重入锁,指的是,一个线程,连续针对一把锁,加锁两次,不会出现死锁,满足这个要求,就是“可重入”,不满足,就是“不可重入”
3.1.2 死锁
3.1.2.1 产生情况
1.一个线程,针对一把锁,连续加锁两次,如果是不可重入锁,就死锁了(synchronized不会出现)
2.两个进程,两把锁(此时无论是不是可重入锁,都会死锁)
t1 t2 A B
1).t1获取锁A,t2获取锁B
2).t1尝试获取锁B,t2尝试获取锁A
public class Demo15 {
private static Object locker = new Object();
public static void func1() {
synchronized (locker) {
func2();
}
}
public static void func2() {
func3();
}
public static void func3() {
func4();
}
public static void func4() {
synchronized (locker) {
}
}
}
这样就构成死锁了。
3.N个线程,M把锁(相当于2的扩充)
此时,是更容易出现死锁的情况了
“哲学家就餐问题” -> 1.思考人生 2.吃面条 -> 哲学家=线程 筷子=锁
3.1.2.2 产生原因
四个条件,缺一不可
1.互斥条件(锁的基本特性) 当一个进程持有一把锁之后,另一个进程也想获取到锁,就要阻塞等待
2.不可抢占(锁的基本特性) 当锁已经被线程1拿到之后,线程2只能等待线程1主动释放,不能强行抢占过来
3.请求保持(代码结构) 一个线程尝试获取多把锁(先拿到锁1之后,再尝试获取锁2 ,获取的时候,锁1不会释放)
4.循环等待 等待的依赖关系,形成环了
3.1.2.3 解决
解决死锁,核心就是破坏上述条件,只要破坏一个,死锁就不能形成了
1和2破坏不了(自带特性,无法干预)
对于3来说,调整代码结构,避免编写“锁嵌套”逻辑
对于4来说,可以约定加锁的顺序,就可以避免循环等待(针对锁,进行编号,比如约定,加多把锁的时候)
public class Demo16 {
private static Object locker1 = new Object(); //醋
private static Object locker2 = new Object(); //酱油
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (locker1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (locker2) {
System.out.println("t1 加锁成功");
}
});
Thread t2 = new Thread(() -> {
synchronized (locker1) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (locker2) {
System.out.println("t2 加锁成功");
}
});
t1.start();
t2.start();
}
}
3.1.2.4 面试题
你是否了解死锁,谈谈你对死锁的理解
3.2 volatile关键字
volatile能保证内存可⻅性
volatile修饰的变量,能够保证"内存可⻅性"
计算机运行的程序/代码,经常要访问数据
这些依赖的数据,往往会存储在内存中(定义一个变量,变量就是在内存中)
cpu使用这个变量的时候,就会把内存中的数据先读出来,放到cpu寄存器中,在参与运算(load)
cpu读取内存的这个操作,其实非常慢(cpu进行大部分操作,都很快,一旦操作到读/写内存,速度就一下就很慢了)
为了解决上述问题,提高效率,此时编译器,就可能对代码做出优化,把一些本来要读内存的操作,优化成读取寄存器,减少读内存的次数,也就可以提高整体程序的效率了(重要)
public class Demo17 {
private static int isQuit = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (isQuit == 0) {
}
System.out.println("t1 退出");
});
t1.start();
Thread t2 = new Thread(() -> {
System.out.println("请输入isQuit:");
Scanner scanner = new Scanner(System.in);
isQuit = scanner.nextInt();
});
t2.start();
}
}
并不会退出,即出现了bug
由于多线程引起的,也就是线程安全问题
此处的问题,就是“内存可见性”引起的,即编译器进行代码优化
而我们要做到的是,告诉编译器不要优化,于是volatile就是解决方案。
public class Demo17 {
private volatile static int isQuit = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (isQuit == 0) {
//
}
System.out.println("t1 退出");
});
t1.start();
Thread t2 = new Thread(() -> {
System.out.println("请输入isQuit:");
Scanner scanner = new Scanner(System.in);
isQuit = scanner.nextInt();
});
t2.start();
}
}
但是,我们好像发现,还有一种办法,不使用volatile也可以正常退出。
public class Demo17 {
private static int isQuit = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (isQuit == 0) {
try {
Thread.sleep(1000); //加上sleep
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t1 退出");
});
t1.start();
Thread t2 = new Thread(() -> {
System.out.println("请输入isQuit:");
Scanner scanner = new Scanner(System.in);
isQuit = scanner.nextInt();
});
t2.start();
}
}
即加上sleep,那么这是为什么呢?
其实就是因为加上sleep后,while执行速度就变慢了,不会触发load的优化,也就没有触发内存可见性问题了。
上述两个办法虽然都可以,但是还是使用volatile更加靠谱!
3.3 内存可见性
关于内存可见性,还涉及到一个关键概念,JMM(Java Memory Model,Java内存模型)
将内存分为主内存和工作内存(main memory和work memory)
t1线程,对应isQuit变量,本身是在主内存中的
由于此处的优化 就会把isQuit变量放到工作内存中
进一步的t2修改主内存的isQuit,不会影响到t1的工作内存(包括了CPU的寄存器和缓存)
volatile和synchronized有着本质的区别。synchronized能够保证原⼦性,volatile保证的是内存可⻅性。
volatile可以解决内存可见性的问题,但不能保证原子性。至于synchronized是否能保证内存可见性,依旧存疑ing。
以上就是我们关于线程安全的学习了。