概述
在前面的多线程专栏中,我们提到线程并发所带来的安全性问题。导致安全性问题的主要原因是多个线程同时操作同一块内存空间。在如何解决该问题时,我们提到:通过 锁 的方式强制同时只能有一个线程操作共享内存,其他线程在该线程释放锁后进行抢锁,只有抢到锁的线程才能向下执行,否则就阻塞等待。在JAVA语言中,Synchronized关键字就可以保证同一时刻,只能有一个线程执行某段代码。在某些并发场景中,也是通过它保证线程的安全性。
Synchronized
本篇主要从以下五个模块展开
- Synchronized 简单介绍
- Synchronized 如何使用
- 可重入锁
- 非公平锁
- 死锁
1、Synchronized 简单介绍
我们可以抽象的理解 Synchronized 是一种锁,通过它可以锁定一段代码。当线程执行到被 Synchronized 锁定的代码块时,首先试着去获取锁资源,如果能获取到,就继续向下执行,如果获取失败,线程阻塞等待其他线程释放锁资源。其中线程在执行完被锁定的代码块时才会释放锁资源。
举个不恰当的例子:游乐场新开了蹦床项目。因为害怕小朋友之间出现碰撞发生危险,游乐场规定每次只能一位小朋友玩耍,每个小朋友玩几分钟后换下一位。没有抢到的小朋友只能在外面等待,当换下一位小朋友时:所有小朋友一拥而上,谁先抢到就是谁玩。
在上述案例中,小朋友就类似线程,蹦床就类似被锁定的代码块,游乐场的规定就类似 Synchronized锁。下面我们具体分析其中隐含的特性:
-
每次只能有一位小朋友玩耍,也就是说最多只能有一个线程执行代码,线程之间是 互斥关系。因此Synchronized 也是一种 互斥锁。
-
所有小朋友一拥而上,谁先抢到就是谁玩,也就是说线抢抢锁这个过程是随机的,因此 Synchronized 也是一种 非公平锁。
-
我们来说一种特殊情况:假如一个小朋友越玩越起劲,不想出来了,这种场景下,其他小朋友也只能在外面等着。也就是说,线程在占有锁的时候,可以再尝试获取锁,也就是说锁对象是可以重复获取的。因此 Synchronized 也是一种 可重入锁。
有了上面的介绍,我们再来看在 JAVA 代码中 Synchronized 如何使用
2、Synchronized 如何使用
在 JAVA 代码中,Synchronized有以下三种使用方法:
- 修饰实例方法
- 修饰静态方法
- 修饰代码块
2-1、修饰实例方法
修饰实例方法的 JAVA 代码是这样的:
public synchronized void methodName();
当使用它修饰实例方法时,锁是当前实例的对象锁。也就是说,线程在执行该方法时,会首先获取 当前对象 的锁,如果能获取到才向下执行。下面我们具体看两组测试用例:
public class SynchronizedTest {
CountDownLatch countDownLatch = new CountDownLatch(2);
static int num = 0;
class Worker implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
incr();
}
countDownLatch.countDown();
}
}
public synchronized void incr() {
num = num + 1;
}
@Test
public void test() {
new Thread(new Worker()).start();
new Thread(new Worker()).start();
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("执行完毕后,num的值为:" + num);
}
}
执行结果:执行完毕后,num的值为:200000
在上述代码中,我们创建两个工作线程,每个工作线程执行被 Synchronized 修饰的实例方法 incr() 100000 次,并通过 CountDownLatch 对象确定输出时所有工作线程执行完毕。其中无论执行多少次,结果总是 200000,也就是说上述代码是线程安全的。
该实例中,Synchronized 锁定的是 SynchronizedTest 类的实例方法,也就说:它锁定的是 SynchronizedTest 实例对象,在JUnit测试中,确定实例唯一,因此两个工作线程竞争的是同一个锁,因此不会出现线程安全问题。
下面我们测试一下:Synchronized 修饰实例方法,但竞争的不是同一个实例对象锁时的情况:
public class SynchronizedTest {
CountDownLatch countDownLatch = new CountDownLatch(2);
static int num = 0;
class Worker implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
incr();
}
countDownLatch.countDown();
}
public synchronized void incr() {
num = num + 1;
}
}
@Test
public void test() {
new Thread(new Worker()).start();
new Thread(new Worker()).start();
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("执行完毕后,num的值为:" + num);
}
}
执行结果:执行完毕后,num的值为:116730 (每次运行结果不一致,几乎无法达到 200000)
在上述代码中,我们将 incr() 方法 移动到 Worker 内部类中。此时虽然我们使用 Synchronized修饰,但代码还是线程不安全的。
该实例中,Synchronized 锁定的是 Worker 类的实例方法,也就说:它锁定的是 Worker 实例对象。而在 JUnit 测试方法中,我们创建了两个 Worker 对象,也就是说:两个线程竞争的不是同一把锁,而是各自对象所对应的锁,因此才会出现线程安全问题。
2-2、修饰静态方法
修饰静态方法的 Java 代码是这样的:
public static synchronized void methodName();
public synchronized static void methodName();
上述两种写法都可以。当使用 synchronized 锁定静态方法时,锁对象是当前类的class对象。也就是说,当线程执行到被synchronized修饰的静态方法时,尝试获取当前类class对象的锁,如果获取到,才能向下执行。
我们回到上述案例二中的代码,尝试将 incr() 方法改为静态方法:
public class SynchronizedTest {
static CountDownLatch countDownLatch = new CountDownLatch(2);
static int num = 0;
static class Worker implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
incr();
}
countDownLatch.countDown();
}
public static synchronized void incr() {
num = num + 1;
}
}
@Test
public void test() {
new Thread(new Worker()).start();
new Thread(new Worker()).start();
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("执行完毕后,num的值为:" + num);
}
}
执行结果:执行完毕后,num的值为:200000
在上述代码中,我们将 incr() 方法改为静态方法后,代码变为线程安全的。
在实例中,Synchronized 锁定的是当前 Worker 类的class对象锁。此时虽然有两个Worker对象,但是它们竞争的是同一个class对象锁,因此不会出现线程安全问题。
2-3、修饰代码块
修饰代码块的 Java 代码是这样的:
synchronized(ClassName.class){}
synchronized(object){}
上述两种方式都可以。也就是说:修饰代码块既可以使用实例对象锁,也可以使用类 class 对象锁。
我认为代码块的方式更优雅和高效。因为通常情况下,java方法不是每一行代码都存在线程安全问题,只有少部分操作共享内存的代码才需要加锁。因此如果直接给方法加锁的话,部分本来就安全的代码也会被阻塞,这样是很影响效率的。使用代码块就可以把需要加锁的代码单独加锁,提高效率。
下面我给出一个使用代码块的简单案例:
public class SynchronizedTest {
static CountDownLatch countDownLatch = new CountDownLatch(2);
static int num = 0;
static class Worker implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
synchronized (Worker.class) {
num = num + 1;
}
}
countDownLatch.countDown();
}
}
@Test
public void test() {
new Thread(new Worker()).start();
new Thread(new Worker()).start();
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("执行完毕后,num的值为:" + num);
}
}
执行结果:执行完毕后,num的值为:200000
在上述代码中,我们把 synchronized 修饰静态方法改为修饰代码块,锁对象还是 Worker 类的 class 对象,因此不会出现线程安全问题。如果我们把 Worker.class 改为 this,锁对象就会变成 Worker类实例,此时两个线程竞争的又变成不同的锁,又会出现线程安全问题。
3、可重入锁
在本篇的第一个模块,我们提出 Synchronized 锁是一种 可重入锁。虽然那个案例不是很恰当,但也说明了部分可重入锁特性:线程请求自身已经占有的锁并且不会阻塞。
证明 Synchronized 是可重入锁也比较简单:让线程获取自己已经占有的锁。如果线程没有阻塞,就说明该锁是可重入锁。我们直接上代码:
public class ReentrantLockTest {
private class Worker implements Runnable {
synchronized void method1() {
System.out.println("当前线程已获得实例锁对象");
method2();
}
synchronized void method2() {
System.out.println("当前线程再次获取实例锁对象");
}
@Override
public void run() {
method1();
}
}
@Test
public void test() {
new Thread(new Worker()).start();
}
}
运行结果:
当前线程已获得实例锁对象
当前线程再次获取实例锁对象
在上述代码中,我们创建了两个 synchronized 修饰的方法。线程启动后,首先需要获取当前实例对象锁,获取到锁后执行 method1() 方法。在执行 method1() 方法期间调用 method2() 方法。此时锁对象还没有释放,又重新获取锁对象。从运行结果来看,method2() 方法正常执行。也就是说,Synchronized 锁是可重入锁。
那么可重入锁有什么用呢?我认为可重入锁最大的好处就是避免了死锁的可能性。
在实际应用场景中,很可能出现多个业务方法都存在线程安全性问题,都需要加锁的可能。假设此时我们都使用Synchronized锁。如果一个业务需要执行多个方法。并且 Synchronized 锁不是可重入锁,我们只能如下所示通过串行的方式进行解决:
method1();
method2();
method3();
...
这种做的坏处显而易见:需要频繁的加解锁操作,性能绝大多数被消耗在加解锁上面。
正是因为Synchronized锁是可重入的,我们可以通过更优雅的方式解决:
method1(){
method2(){
method3(){
...
}
}
}
也就是说我们可以在方法内部调用,并且不会造成死锁,这也正是锁可重入的意义。
4、非公平锁
提起非公平锁,我们首先给出公平锁的概念:可以按照线程请求锁的顺序依次获取锁。那非公平锁自然就是不会按请求顺序获取锁。
下面我们通过简单的案例加以证明:
public class UnFairLockTest {
CountDownLatch countDownLatch = new CountDownLatch(20);
class Worker implements Runnable {
@Override
public void run() {
method();
countDownLatch.countDown();
}
}
synchronized void method() {
String methodName = Thread.currentThread().getName();
System.out.println(methodName + "获取到锁");
}
@Test
public void test() {
for (int i = 0; i < 20; i++) {
new Thread(new Worker()).start();
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
执行结果:
Thread-0获取到锁
Thread-1获取到锁
Thread-10获取到锁
Thread-3获取到锁
...
在上述案例中,我们依次创建编号从0到19的线程请求锁,但从执行结果来看,并没有按照线程的请求顺序依次获取锁。因此也就说明 Synchronized 锁是非公平锁。
不过需要明确的一点是:非公平锁在大多数场景下性能更好。因为非公平锁不需要维护数据结构来保证获取锁的顺序,并且 CPU 调度线程本来就是随机的,非公平锁更契合这种模式。
5、死锁
死锁是并发编程中最常见的错误:简答来说,就是两个线程互相占有对方需要请求的资源。此时两个线程都不愿让步(释放资源),导致线程都无法正常执行。当然死锁产生不单单仅限两个线程,只要形成环形等待关系都会产生死锁。
虽然说死锁很常见,但不是说不避免。一般好的并发编程代码都不会产生死锁问题,因为死锁问题一旦产生,从外部根本无法解决。如果没有提前预留解决方法,只能重启应用。
在 synchronized 锁中,因为线程会独占锁资源,因此也有可能产生死锁问题,作为开发者我们要尽量避免。下面我给出一个死锁的例子:
public class DeadLockTest {
CountDownLatch countDownLatch = new CountDownLatch(2);
class Worker1 implements Runnable {
@Override
public void run() {
String threadName = Thread.currentThread().getName();
synchronized (Worker1.class) {
System.out.println(threadName + "获取到Worker1类锁");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (Worker2.class) {
System.out.println(threadName + "获取到Worker2类锁");
}
}
countDownLatch.countDown();
}
}
class Worker2 implements Runnable {
@Override
public void run() {
String threadName = Thread.currentThread().getName();
synchronized (Worker2.class) {
System.out.println(threadName + "获取到Worker2类锁");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (Worker1.class) {
System.out.println(threadName + "获取到Worker1类锁");
}
}
countDownLatch.countDown();
}
}
@Test
public void test() {
new Thread(new Worker1()).start();
new Thread(new Worker2()).start();
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
执行结果:
Thread-0获取到Worker1类锁
Thread-1获取到Worker2类锁
在上述代码中。线程A首先获取 Worker1 类的class锁对象,线程B获取 Worker2 类的class锁对象。后面线程A请求 Worker2 类的class锁对象,线程B请求Worker1 类的class锁对象,他们请求的资源分别被对方所占有,产生死锁,线程永远无法执行。