Java锁系列——1、Synchronized 简单介绍及使用

概述

在前面的多线程专栏中,我们提到线程并发所带来的安全性问题。导致安全性问题的主要原因是多个线程同时操作同一块内存空间。在如何解决该问题时,我们提到:通过 的方式强制同时只能有一个线程操作共享内存,其他线程在该线程释放锁后进行抢锁,只有抢到锁的线程才能向下执行,否则就阻塞等待。在JAVA语言中,Synchronized关键字就可以保证同一时刻,只能有一个线程执行某段代码。在某些并发场景中,也是通过它保证线程的安全性。


Synchronized

本篇主要从以下五个模块展开

  1. Synchronized 简单介绍
  2. Synchronized 如何使用
  3. 可重入锁
  4. 非公平锁
  5. 死锁

1、Synchronized 简单介绍

我们可以抽象的理解 Synchronized 是一种锁,通过它可以锁定一段代码。当线程执行到被 Synchronized 锁定的代码块时,首先试着去获取锁资源,如果能获取到,就继续向下执行,如果获取失败,线程阻塞等待其他线程释放锁资源。其中线程在执行完被锁定的代码块时才会释放锁资源。

举个不恰当的例子:游乐场新开了蹦床项目。因为害怕小朋友之间出现碰撞发生危险,游乐场规定每次只能一位小朋友玩耍,每个小朋友玩几分钟后换下一位。没有抢到的小朋友只能在外面等待,当换下一位小朋友时:所有小朋友一拥而上,谁先抢到就是谁玩。

在上述案例中,小朋友就类似线程,蹦床就类似被锁定的代码块,游乐场的规定就类似 Synchronized锁。下面我们具体分析其中隐含的特性:

  • 每次只能有一位小朋友玩耍,也就是说最多只能有一个线程执行代码,线程之间是 互斥关系。因此Synchronized 也是一种 互斥锁

  • 所有小朋友一拥而上,谁先抢到就是谁玩,也就是说线抢抢锁这个过程是随机的,因此 Synchronized 也是一种 非公平锁

  • 我们来说一种特殊情况:假如一个小朋友越玩越起劲,不想出来了,这种场景下,其他小朋友也只能在外面等着。也就是说,线程在占有锁的时候,可以再尝试获取锁,也就是说锁对象是可以重复获取的。因此 Synchronized 也是一种 可重入锁

有了上面的介绍,我们再来看在 JAVA 代码中 Synchronized 如何使用


2、Synchronized 如何使用

在 JAVA 代码中,Synchronized有以下三种使用方法:

  1. 修饰实例方法
  2. 修饰静态方法
  3. 修饰代码块

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锁对象,他们请求的资源分别被对方所占有,产生死锁,线程永远无法执行。


参考:
https://blog.csdn.net/javazejian/article/details/72828483
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值