JavaEE初阶第六期:解锁多线程,从 “单车道” 到 “高速公路” 的编程升级(四)
专栏:JavaEE初阶起飞计划
个人主页:手握风云
目录
一、synchronized关键字
1.1. synchronized的特性
1.2. synchronized使用实例
1.3. synchronized的设计理念
1.4. Java标准库中的线程安全类
一、synchronized关键字
前面提到了线程安全的问题,造成线程不安全的原因可能是因为修改操作不是“原子性”的,那我们就可以通过修改来把一些把修改操作变成“原子”的。此时我们就可以通过synchronized监视器锁进行加锁的方式,把一段代码打包成一个整体,从而达到“原子性”的效果。
1.1. synchronized的特性
- 互斥
synchronized会起到互斥效果,某个线程执⾏到某个对象的synchronized中时,其他线程如果也执行到同⼀个对象synchronized就会阻塞等待。
对于锁这样的概念,涉及到两个核心操作:1.加锁;2.解锁。
Thread t1 = new Thread(() -> {for (int i = 0; i < 50_000; i++) {// 进来就是加锁synchronized (lock) {count++;}// 出去就是解锁}
});
synchronized(),括号里面填的是“锁对象”,在Java中,任何一个对象都可以视作“锁对象”。加锁是把若干个操作打包成“一个原子”,既不是把count++这一个指令变成三个指令,也不是三个指令就必须要一口气在CPU上执行完,不会触发调度。加锁会影响其他的加锁线程,而且是加同一个锁的线程。如下图所示,第一个滑稽老铁进入厕所后加锁成功,第二个想上厕所的滑稽老铁向上厕所就只能等第一个滑稽老铁出来后才能进入。此时第二个滑稽因为锁被占用而无法使用厕所,只能阻塞,此时成为“锁竞争”或者“锁冲突”。
如果一个线程加锁,另一个没加锁,是不会造成阻塞的。只有两个线程都加锁了,并且是同一个对象,才会产生锁竞争。如下面的代码所示,我们对其中一个线程加锁,运行结果依然不对。
public class Demo1 {private static int count = 0;private static Object lock = new Object();public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 0; i < 50_000; i++) {// 进来就是加锁synchronized (lock) {count++;}// 出去就是解锁}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50_000; i++) {count++;}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
public class Demo1 {private static int count = 0;private static Object lock = new Object();public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 0; i < 50_000; i++) {// 进来就是加锁synchronized (lock) {count++;}// 出去就是解锁}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50_000; i++) {// 第二个线程也加锁synchronized (lock) {count++;}}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
此处的加锁和解锁,相当于两条CPU指令,当t1线程解锁的时候,说明t1线程已经将加1的值从寄存器返回到内存中,当t2线程继续执行加载操作时,就是t1已经保存过的数据。本来t1、t2两个线程中的load、add、save是穿插执行的,但引入锁之后,就变成了“串行执行”。
如果两个线程没有写入同一个锁对象,那就不会产生阻塞,程序的执行也就不会出现“禁止插队”的情况。就如同厕所里面有多个位置,当第一个滑稽老铁进入了第一个位置加锁后,不影响第二个滑稽老铁上厕所。
public class Demo1 {private static int count = 0;private static Object lock1 = new Object();private static Object lock2 = new Object();public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 0; i < 50_000; i++) {// 进来就是加锁synchronized (lock1) {count++;}// 出去就是解锁}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50_000; i++) {// 第二个线程也加锁synchronized (lock2) {count++;}}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
那如果我们把synchronized拿到for循环外面,运行结果也是10w。
public class Demo2 {private static int count = 0;private static Object lock = new Object();public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {synchronized (lock) {for (int i = 0; i < 50_000; i++) {count++;}}});Thread t2 = new Thread(() -> {synchronized (lock) {for (int i = 0; i < 50_000; i++) {count++;}}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
synchronized在for循环里面时,相当于加锁解锁操作循环执行了5w次。synchronized在for循环外面时,当t1线程加上锁之后,t1就会不停地执行循环,循环了5w次后,才释放锁。此时两个线程的循环是完全串行的,也可以看作是单个线程执行的。第一种写法,代码的并发程度更高,更好地利用了CPU多核心资源。
public class Demo2 {private static int count = 0;private static Object lock = new Object();public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {synchronized (lock) {for (int i = 0; i < 50_000; i++) {count++;}}});Thread t2 = new Thread(() -> {synchronized (lock) {for (int i = 0; i < 50_000; i++) {count++;}}});long beg = System.currentTimeMillis();t1.start();t2.start();t1.join();t2.join();long end = System.currentTimeMillis();System.out.println(count);System.out.println("耗时:" + (end - beg) + "ms");main1(args);}public static void main1(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 0; i < 50_000; i++) {// 进来就是加锁synchronized (lock) {count++;}// 出去就是解锁}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50_000; i++) {// 第二个线程也加锁synchronized (lock) {count++;}}});long beg = System.currentTimeMillis();t1.start();t2.start();t1.join();t2.join();long end = System.currentTimeMillis();System.out.println("耗时:" + (end - beg) + "ms");}
}
第一种写法明显比第二种写法快,如果上述代码逻辑很复杂,加锁的代码块开销很大,并发程度越低,执行速度很慢,会导致时间很大。
- 可重入
我们假设一个场景,如果一个线程针对一把锁,连续加锁两次,会怎么样?
public class Demo4 {private static int count = 0;private static Object lock = new Object();public static void main(String[] args) {Thread t1 = new Thread(() -> {synchronized (lock) {synchronized (lock) {for (int i = 0; i < 50_000; i++) {count++;}}}});Thread t2 = new Thread(() -> {synchronized (lock) {for (int i = 0; i < 50_000; i++) {count++;}}});}
}
初始情况下,假定lock是未加锁状态,此时的synchronized就会加锁成功,继续向下执行。第二次加锁的时候,这个锁已经是“被加锁”的时候。如果这个锁已经被加锁了,再次尝试进行加锁操作,也会触发锁冲突,此时该线程就会阻塞等待,一直等到锁被释放。
走到第二个加锁位置,触发阻塞。要想解除阻塞,得先释放第一个锁。要想释放第一个锁,就得把第二个锁加上,继续往下走。这种情况下,像是进入了循环,这就是死锁。在没有人工干预的情况下,就会一直阻塞。
这个代码虽然逻辑有点奇怪,但我们稍不留神就可能写出如下的嵌套循环代码,这样的代码在Java中就不会死锁。同一个线程,针对同一把锁,连续加锁多次,不会触发死锁,此时合格所就被称为“可重入锁”。
void menthod1() {synchronized (this) {menthod2();}
}void menthod2() {menthod3();
}private void menthod3() {menthod4();
}void menthod4() {}
1.2. synchronized使用实例
synchronized除了加锁对象修饰代码块,还可以修饰成员方法,此时是针对this加锁,可以吧锁对象省略。如下面的代码所示:
public class Demo3 {static class Counter {private int count = 0;synchronized public void Add() {count++;}}public static void main(String[] args) throws InterruptedException {Counter counter = new Counter();Thread t1 = new Thread(() -> {for (int i = 0; i < 50_000; i++) {counter.Add();}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50_000; i++) {counter.Add();}});t1.start();t2.start();t1.join();t2.join();System.out.println(counter.count);}
}
这种写法等价于如下代码。下面两种写法没有任何区别,锁对象,锁的是啥对象不重要,重要的是两个线程是否针对同一对象加锁。通俗点说,就是上锁是为了上厕所时别人进不来,别人只是和你竞争同一个锁,而这个锁是什么样的,我们不关心。
public void Add() {synchronized (this) {count++;}
}public void Add() {synchronized (lock) {count++;}
}
此外,synchronized还可以修饰static修饰的静态方法。由于静态成员方法没有this引用,此时认为synchronized是针对类对象加锁。
1.3. synchronized的设计理念
Java加锁的风格与其他语言的差异较大,以C++为例,大部分代码都采用如下风格。但这种写法存在缺陷,因为unlock可能调用不到,就如同人走了,锁没打开,导致后续使用的人会打不开。
std::mutex locker;locker.lock();//加锁locker.unlock();//解锁
void func() {lock();……if () {return;}unlock();
}
我们细品一下上面的代码,如果中间if语句的条件成立,不会往下执行,就导致不会执行下面的代码,我们虽然可以在if语句之前加上unlock(),但如果代码逻辑复杂,存在多个if语句,如果是自己写的代码还好,但如果别人来修改这段代码,他有可能会不知道要加unlock()。假设func()中没有任何return,也不一定100%执行unlock(),还有一种情况就是抛出异常导致没有执行unlock()。
当然我们也可以使用try{}catch{}进行捕获,不管是return还是都是都能够执行到,但代码写起来还是比较麻烦,不如synchronized方便。
void func() {try {lock();} finally {unlock();}
}
1.4. Java标准库中的线程安全类
Java标准库中很多都是线程不安全的。这些类可能会涉及到多线程修改共享数据,又没有任何加锁措施。比如,ArrayList、LinkedList、HashMap、TreeMap、HashSet、TreeSet、StringBuilder。
但是还有⼀些是线程安全的,使⽤了⼀些锁机制来控制。比如:Vector、HashTable、StringBuffer。
还有的虽然没有加锁,但是不涉及"修改",仍然是线程安全的。比如String。