【JavaEE】多线程(2)
一、线程安全
1.1 线程安全的概念
线程是随机调度执行的,如果多线程环境下的程序运行的结果符合我们预期则说明线程安全,反之,如果遇到其他结果甚至引起了bug则说明线程不安全
1.2 经典例子与解释
下面举一个经典的线程不安全的例子:
public class Demo2 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = "+ count);
}
}
上述代码中t1和t2两个线程对count进行累加操作,在主线程中启动这两个线程然后通过join等待这两个线程都执行完后打印count,预期结果为100000,但打印结果如下:
上述结果不符合我们的预期,这便是产生了线程安全问题
接下来我们通过CPU指令的方式解释上述原因
count++这个行代码可以看作3个CPU指令:
- 把内存count总的值读取到CPU寄存器中 => load
- 把寄存器中的值+1,此时任然在寄存器中 =>add
- 把上述寄存器计算后的值写回到内存count里 =>save
由于线程随机调度,所以两个线程的CPU指令执行顺序也是随机的。
例如下图:
(画图,时间轴。。。。。。。。。。)
首先t1线程和t2线程分别将1加载到CPU寄存器中(假设此时count的值为1),然后在寄存器中将其加1变为2,最后t1先将2加载回内存中,t2也把2加载回内存中,所以两次加1操作只加了一次1
当然上述执行顺序只是无数可能中的一种,可能t1的一组指令还没有执行完,t2就执行了好几组
下面来总结一下线程不安全的原因
1.3 线程不安全的原因
- 线程是随机调度,抢占式执行的
- 修改共享数据,多个线程修改同一个变量
- 多个线程修改共享数据的操作不是原子性,(count++是3个CPU指令,但是赋值操作就是原子性的)
- 内存可见性问题
- 指令重排序
4和5后面再解释
1.4 解决线程安全问题
根据上述原因下手
原因1:无法干预
原因2:可以干预,但并不是一个普适的做法,因为有些代码就是要修改同一个变量
原因3:这是一个普适的做法,我们可以将一系列非原子的操作打包成一个原子性的操作->加锁
1.4.1什么是锁
锁是在多线程编程中用来控制线程对共享资源访问的一种机制
1.针对锁主要有这两个操作:
- 加锁:线程t1加上锁之后,t2也尝试使用同一个锁进行加锁,就会阻塞等待
问:什么叫“t2也尝试使用同一个锁进行加锁”?
答:你可以理解为我们给t2里的操作加上了一种机制,这个机制就是必须加上锁才能进行操作,t1拿了一个锁,加锁后进行它的操作,如果t2也想拿这个锁来加锁就必须等t1操作完成解锁之后,再拿这个锁进行加锁进行它的操作,在此之前t2要阻塞等待,当然,如果t2选择拿别的锁进行加锁就不会阻塞等待(假设只有t1和t2两个线程)
比如,A在餐厅里定了一个包间,把门上锁之后来用餐,这样B来了就不会影响A用餐的过程;也就是t2不会对t1修改count的过程进行干扰,这样就保证了操作的原子性
- 解锁:t1解锁之后,t2才有可能拿到锁,因为尝试竞争锁的线程可能不只一个
2. 锁的主要特性:互斥,一个线程获取到锁之后,另一个线程也尝试加这个锁,就会阻塞等待,这种现象叫锁竞争或锁冲突,代码中也可以有多个锁,只有多个线程竞争同一个锁才会发生锁竞争,竞争不同的锁则不会发生锁竞争
1.5 synchronized关键字
1.5.1 synchronized解读
使用synchronized关键字,synchronized关键字解读:
synchronized (locker) {
count++;
}
- 这是一个Java的关键字,不是方法
- synchronized后面括号里面写的是锁对象
- 锁对象的用途:用来区分两个线程是否针对同一个对象加锁,如果是,就会出现锁竞争/互斥就会引起阻塞等待,如果不是就不会出现锁竞争,也就不会阻塞等待
-
synchronized的{ }:进入到代码块,就是对上述锁对象进行加锁操作,当出了代码块,就是对锁对象进行解锁
我们可以让t1和t2都使用同一个锁对象locker来对count变量的修改操作进行上锁
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 < 50000; i++) {
synchronized (locker) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (locker) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = "+ count);
}
这样,虽然两个线程仍然是抢占式执行的,但是保证了count++;这个操作的原子性,结果为:count = 100000
Java中随便拿一个对象,都可以作为加锁的对象
1.5.2 synchronized使用示例
1)修饰代码块:指定锁哪个对象,也就是可以锁任意对象
public class SynchronizedDemo {
private Object locker = new Object();
public void method() {
synchronized (locker) {
}
}
}
锁当前对象:()里直接写this
public class SynchronizedDemo {
public void method() {
synchronized (this) {
}
}
}
2)修饰普通方法:锁的SynchronizedDemo对象,谁调用method()方法,就锁谁(可以有多个)
public class SynchronizedDemo {
public synchronized void methond() {
}
}
3)修饰静态方法:锁的SynchronizedDemo类对象(一个java进程中,一个类只有唯一一个类对象)
public class SynchronizedDemo {
public synchronized static void method() {
}
}
1.5.3 synchronized特性
1)互斥
某个线程执行到某个对象的synchronized中时,其他线程如果也执行到同一个对象,synchronized就会阻塞等待
2)可重入
for (int i = 0; i < 50000; i++) {
synchronized (locker) {
synchronized (locker) {
count++;
}
}
}
上述线程先对locker进行第一次加锁,在第二次加锁的时候,locker对象已经被锁住了,按照之前的理解,尝试针对一个已经被锁的对象加锁时,就会阻塞等待,这种情况就叫死锁
但synchronized是可重入锁,可重入锁的内部包含了线程持有者和计数器
- 如果某个线程加锁的时候,发现这个锁已经被别人占用,但是恰好占用的是自己,那么仍然可以继续获取到锁,并让计数器自增
- 解锁的时候(也就是每走出一个代码块)计数器就会递减,当减到0时才真正释放锁
这种机制就叫可重入锁
1.6 死锁
1.6.1 两个常见的场景
死锁有两个比较典型的场景
场景一:不可重入锁引起的死锁
一个线程对一个线程连续加锁两次且这个锁是不可重入锁就会引起死锁
场景二:两个线程两把锁
public static void main(String[] args) throws InterruptedException {
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();
System.out.println("死锁ing....");
}
上述代码中,locker1被t1占用,locker2被t2占用,接下来t1需要locker2,t2需要locker1,这样就陷入了死锁
运行结果显示程序一直没有结束:
1.6.2 如何避免死锁
死锁产生的四个必要条件:
1)锁具有互斥性
2)锁不可抢占:一个线程拿到锁之后,除非它主动释放锁,否则别人抢不走
以上这两点是锁的基本特性,无法干预
3)请求和保持:一个线程拿到一把锁之后,不释放这个锁的前提下,在尝试获取其他锁(嵌套加锁)
解决方法就是不要让两个sychronized嵌套式的占用两个不同的锁对象进行加锁
4)循环等待:多个线程获取多个锁的过程中,出现了循环等待,A等待B,B又等待A
这一点只要我们提前约定好获取锁的顺序,即使出现了嵌套也不会引起死锁,如下述代码t1和t2线程都先获取locker1再获取locker2,这样就不会出现死锁
public static void main(String[] args) throws InterruptedException {
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();
}
任何一个死锁的场景,都必须同时具备上述四点,缺少一点都不会构成死锁
🙉本篇文章到此结束,下篇文章将继续对线程安全的知识进行讲解