下面有两段代码:
public class test {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
t1.start();
t1.join(); //程序这样写,相当于线程顺序执行
t2.start();
t2.join();
System.out.println(count);
}
}
上面代码中每个线程先start(),再join(),这样就相当于线程顺序执行了。不会发生线程不安全。
public class test {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
显然,第二个代码的打印结果出出现了线程不安全。一个原因是因为count++这个操作不是原子的。
我们首先来看count++这个操作在CPU上是怎样执行的?
count++在CPU上执行大致分为三步:
- load 把数据从内存读到cpu寄存器中
- add 把寄存器中的数据进行+1
- save 吧寄存器中的数据保存到内存中。
那么当多个线程执行count++操作时,由于线程的调度顺序是随机的,就会如下图所示,假设执行顺序从上到下。
上述组合情况只列了四个,还是在两个线程的情况下,如果线程更多,那排列组合的情况也会更多。那么这种线程安全问题如何解决呢?为此我们引入加锁这种方式来解决。
解决办法: 加锁 synchronized
- synchronized修饰的是一个代码块。同时会指定一个"锁对象"。在进入代码块的时候,对该对象进行加锁,在出代码块的时候,对该对象进行加锁。
- 锁对象一般是临时创建出来的,Object型的,对象是谁不重要,关键是两个线程加锁的对象是否是同一个。
- 这样的好处就是当多个线程针对同一个对象进行加锁时,就会出现"锁竞争/锁冲突",并且只有当线程拿到锁之后,才能继续执行代码;没拿到锁时,就只能阻塞等待。等待拿到锁的线程释放锁之后,它才有机会拿到锁,继续执行。
- 这样的规则,本质上是把"并发执行"变成"串行执行"了。
- 并且加锁后可以将不是原子的 count++ 变成原子的。
即如图两种情况所示:
// 加锁 针对同一个变量进行修改时存在的线程问题的 例如下面的 count++
public class Demo10 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
//创建一个对象
Object locker = new Object();
Thread t1 = new Thread( () -> {
for (int i = 0; i < 10000; i++) {
synchronized (locker) { //加锁方式 ()中需要表示一个用来加锁的对象 synchronized 还可以修饰方法
count++;
}
}
});
Thread t2 = new Thread( () -> {
for (int i = 0; i < 10000; i++) {
synchronized (locker) { //加锁方式 ()中需要表示一个用来加锁的对象
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
可以看到,加锁之后,符合预期结果了。
产生线程安全问题的原因 (面试题)
- 操作系统中,线程的调度顺序是随机的(抢占式执行)。
- 当两个或多个线程针对同一个变量进行修改时,如count++。
- 修改操作不是原子的。count++就是非原子的。
- 内存可见性问题。
- 指令重排序问题。
synchronized修饰的两种方法
- 修饰静态方法 即static修饰的方法。
- 修饰非静态普通方法。
1.修饰静态方法,实则修饰类对象 synchronized (类名.class) { } 类对象在一个java进程中是唯一的。
public static int count = 0;
//修饰静态方法简化
synchronized public static void increase() { //修饰静态方法 实则修饰类对象 加锁
count++;
}
//修饰静态方法完整
public static void increase() {
synchronized (Counter.class) { //类对象
count++;
}
}
2.修饰非静态普通方法,实则修饰this synchronized (this) { }
public static int count = 0;
//修饰非静态方法简化 实则修饰this
synchronized public void increase() {
count++;
}
//修饰非静态方法完整
public void increase() {
synchronized (this) { //this
count++;
}
}
synchronized的特性
可重入锁 可重入锁是如何实现的?
- 使用一个专门的属性来记录当前是那个线程加的锁。
- 引入一个计数器count++来记录当前已经加了多少层锁,还需要减多少下count--才可以真正释放锁。
- 即从最外面一层锁出来后才释放锁,中间的 synchronized 不会释放锁。
死锁
- 所谓的死锁就是线程一拥有锁1,线程二拥有锁2,双方在拥有自身锁的同时尝试获取对方的锁,最终两个线程就会进入无线等待的状态,这就是死锁。相当于门上钥匙锁车里了,车钥匙锁家里了。
- 注意在代码中的两个sychronized是嵌套关系,不是并列关系,嵌套关系是指在占用一把所得前提下,获取另一把锁(会可能出现死锁)。而并列关系则是先释放前面的锁,再获取下一把锁(不会出现死锁)。
- 还要注意代码中的的sleep,很重要,目的是保证两个线程先拿到各自的锁。
public static void main(String[] args) {
//创建两个锁对象
Object locker1 = new Object();
Object locker2 = new Object();
//创建两个线程
Thread t1 = new Thread(() -> {
synchronized (locker1) {
try {
//此处的sleep很重要,确保t1和t2都拿到一把锁之后,再往后执行
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName());
synchronized (locker2) {
System.out.println(Thread.currentThread().getName());
}
}
},"t1");
Thread t2 = new Thread(() -> {
synchronized (locker2) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName());
synchronized (locker1) {
System.out.println(Thread.currentThread().getName());
}
}
},"t2");
t1.start();
t2.start();
}
运行代码,并用jconsole观察:
不难发现,两个线程都处于BLOCKED阻塞状态,想要获取的锁都被对方拥有。
死锁的成因及如何解决??
成因有四个必要条件:
- 互斥使用。虽然是锁的基本特性,但也不可避免的会造成死锁,即当一个线程已经拥有一把锁之后,另一个线程也想获取到此锁,就要阻塞等待。
- 不可抢占。也是锁的基本特性。即当一个锁已经被线程1得到之后,线程2要想再获取此锁,就只能等待线程1主动释放锁,不能强行去抢。
- 请求保持。代码结构,程序员写的逻辑。即一个线程尝试去获取多把锁(先拿锁1,再尝试获取锁2,获取锁2的时候,锁1并不会释放),嵌套synchronized。
- 循环等待/环路等待。等待的依赖关系,形成环了。
解决死锁的方法
只要破坏上述必要条件中的任意一条就行,但前两条本身就是锁的特性,破坏不了,故破坏后两条任意一条就行。
对于第三条:调整代码逻辑,避免"锁嵌套"逻辑。也得看实际需求,可能实际需求就是会有"锁嵌套"这种逻辑。所以第四条的破坏就尤为重要。
public class bbbbbbbb {
public static void main(String[] args) {
//创建两个锁对象
Object locker1 = new Object();
Object locker2 = new Object();
//创建两个线程
Thread t1 = new Thread(() -> {
synchronized (locker1) {
try {
//此处的sleep很重要,确保t1和t2都拿到一把锁之后,再往后执行
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//挪出来
synchronized (locker2) {
System.out.println("t1 加锁成功!");
}
},"t1");
Thread t2 = new Thread(() -> {
synchronized (locker2) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (locker1) {
System.out.println("t2 加锁成功!");
}
},"t2");
t1.start();
t2.start();
}
}
对于第四条;可以约定加锁的顺序,就可以避免循环等待。即针对锁进行编号。比如约定在再多把锁的时候,先加编号小的锁,再加编号大的锁。规定所有线程都要遵守这个规则。
面试题: 你是否了解死锁,谈谈对死锁的理解???
volatile 关键字作用及使用
- 保证内存可见性
- 禁止指令重排序
1).保证内存可见性
由于计算机运行的程序/代码,经常要访问数据,这些数据被存储在内存中。CPU在使用这个变量的时候,就会把内存中的数据先读出来,再放到CPU的寄存器中,再参与运算。(load 在上文count++的三步时讲过)。
寄存器:我们通常都知道,读内存的速度远远大于读外存的速度,而读寄存器的速度却又远远快于内存。CPU在进行大部分操作时都很快,但一旦操作到读/写内存时速度就变慢了。
为了解决这个变慢问题,提高效率,此时编译器就可能会对代码做出优化,即把一些本来要度内存的操作,优化成读取寄存器了,这样就会减少读内存的操作,也就会提高整体程序的效率了。但这种优化操作并不一定是我们想要的。于是也要去解决防止编译器自动优化。方法如下。
示例多线程代码:
预期目标是改变isQuit的值,然后打印 t1 线程执行结束!
import java.util.Scanner;
public class test {
//两个线程
//预期目标是改变isQuit的值,然后打印 t1 线程执行结束!
public static int isQuit = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while(isQuit == 0) {
}
System.out.println("t1 线程执行结束");
});
Thread t2 = new Thread(() -> {
Scanner sc = new Scanner(System.in);
System.out.print("请输入 isQuit=");
isQuit = sc.nextInt();
});
t1.start();
t2.start();
}
}
结果是程序并没有停止运行。此时就是发生内存可见性问题,即编译器通过优化读取操作,把读内存优化成都寄存器了,而改变后的值在却内存中,虽然是读速度变快了,但是有可能也就不准了,这样的优化操作也无疑会使预期结果出错。
如何解决?即对该变量加上修饰符 volatile。就会禁止编译器做这种优化操作。
import java.util.Scanner;
public class test {
//两个线程
//预期目标是改变isQuit的值,然后打印 t1 线程执行结束!
public volatile static int isQuit = 0; //加 volatile
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while(isQuit == 0) {
}
System.out.println("t1 线程执行结束");
});
Thread t2 = new Thread(() -> {
Scanner sc = new Scanner(System.in);
System.out.print("请输入 isQuit=");
isQuit = sc.nextInt();
});
t1.start();
t2.start();
}
}
或者,不加 volatile ,给循环里加个 sleep 也行,原因是加了sleep之后,while循环的执行速度变慢了,这样load操作的开销就不大了。因此优化也就没必要进行了。故没有触发load的优化,也就没内存可见性问题了。但总的来说,编译器优不优化,拿捏不准,还是最好使用volatile更靠谱。
不过这种优化前提是在一个线程中使用,另一个线程中修改isQuit的值,编译器以为没人修改isQuit,就做出了优化。如果都是在一个线程中使用并修改,就不会有BUG了。
import java.util.Scanner;
public class aaaaaaaa {
//两个线程
//预期目标是改变isQuit的值,然后打印 t1 线程执行结束!
public volatile 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 线程执行结束");
});
Thread t2 = new Thread(() -> {
Scanner sc = new Scanner(System.in);
System.out.print("请输入 isQuit=");
isQuit = sc.nextInt();
});
t1.start();
t2.start();
}
}
"内存可见性"问题
- CPU先load操作读取内存中isQuit的值到寄存器中。
- 再通过汇编语言cmp指令比较寄存器中的值是否为0,并决定是否要进行while循环。
- 由于这个while循环速度非常快,短时间内会进行大量的循环,也就是进行大量的load和cmp操作。此时,编译器/JVM就会发现虽然进行了折磨多次的load,但是load书来的结果都是一样的,而且load操作有非常浪费时间,可以说一次load顶得上上万次cmp操作了。于是编译器就做了一个决定。只是在第一次循环的时候,去读内存即进行load操作,后续就不去再读内存了,而是直接从寄存器中读isQuit的值,即进行只cmp操作了,这样就即使是isQuit的值改变了,也感知不到。
若对于什么是原子操作,内存可见性,指令重排序,类对象不懂的等可看气的下一篇博客。