Java并发编程丨互斥同步

首页 / 默认分类 / 正文

前言

非线程安全问题会在多个线程对同一个对象中的实例变量进行并发访问时发生,产生的结果就是脏读(dirty read)——读取实例变量的值已经被其他线程更改过了,而实现线程安全的方法之一就是互斥同步。在 Java 中除了提供 Lock 外,还在语法层面上提供了关键字 synchronized 来实现互斥同步原语。

关键字 synchronized

关键字 synchronized 可用来保障原子性、可见性和有序性。使用关键字 synchronized 的写法比较多,常见的有以下几种:

public class SyncDemo {

    public synchronized static void testMethod1() {
        System.out.println("testMethod-1");
    }

    public synchronized void testMethod2() {
        System.out.println("testMethod-2");
    }

    public void testMethod3() {
        synchronized (SyncDemo.class) {
            System.out.println("testMethod-3");
        }
    }

    public void testMethod4() {
        synchronized (this) {
            System.out.println("testMethod-4");
        }
    }

    public void testMethod5() {
        synchronized (obj) {
            System.out.println("testMethod-5");
        }
    }
}

同步方法

使用很简单,只需要在方法前加上关键字 synchronized 即可。

public synchronized void addOne() {}

修饰静态方法

synchronized 修饰静态方法,实际上是对该类对象加锁,俗称“类锁”,也就是类名.class
示例:修饰两个 static 方法,假如有线程 A 执行 testMethod1,线程 B 执行 testMethod2。不管哪个线程先运行,这个线程进入用 synchronized 声明的方法时就上锁,方法执行完后自动解锁,之后下一个线程才会进入用 synchronized 声明的方法里。这里线程 A 大概先启动先执行,两秒后线程 A 执行完毕释放锁,线程 B 才能获取到锁。

public class SyncDemo {

    // 修饰静态方法
    public synchronized static void testMethod1() {
        try {
            TimeUnit.SECONDS.sleep(2);
        }catch(InterruptedException e){
            e.printStackTrace();
        }
        System.out.println("testMethod-1");
    }

    // 对比组
    public synchronized static void testMethod2() {
        System.out.println("testMethod-2");
    }
}

public class SyncDemoTest {
    public static void main(String[] args) {
        new Thread(() -> {
            SyncDemo.testMethod1();
        }, "A").start();

        new Thread(() -> {
            SyncDemo.testMethod2();
        }, "B").start();
    }
}

由于锁的是 SyncDemo.java 对应 Class 类的对象,而 SyncDemo.java 只有一个 SyncDemo.class,所以 static 方法获取到的锁是同一把锁。以下代码与上边是相同的:

// 只有一个
Class<SyncDemo> c = SyncDemo.class;

// 修饰静态方法
public synchronized static void testMethod1() {
    System.out.println("testMethod-1");
}

public void testMethod3() {
    // 修饰本类 Class 类的对象
    synchronized (SyncDemo.class) {
        System.out.println("testMethod-3");
    }
}

用类直接在两个线程中调用两个不同的静态同步方法,会产生互斥。但如果 testMethod2 没有用 synchronized 修饰,则不涉及同步问题,在上述情景下,线程 B 直接执行,不会试图获取锁。

修饰成员方法

synchronized 修饰成员方法,获得的锁是对象锁,也就是说,在 X 对象中使用了 synchronized 关键字声明的非静态方法,那么 X 对象就被当成锁。以下两种写法也是一样的:

// 修饰成员方法
public synchronized void testMethod2() {
    System.out.println("testMethod-2");
}

public void testMethod4() {
    // 修饰对象锁
    synchronized (this) {
        System.out.println("testMethod-4");
    }
}

与类锁不同,不同对象(对象锁不是同一把锁)在两个线程中调用同一个同步方法,不会产生互斥。

public class SyncDemoTest {
    public static void main(String[] args) {
        // 两个不同实例对象,不同的锁
        SyncDemo sd1 = new SyncDemo();
        SyncDemo sd2 = new SyncDemo();

        new Thread(() -> {
            sd1.testMethod2();
        }).start();

        new Thread(() -> {
            sd2.testMethod2();
        }).start();
    }
}

同步代码块

synchronized 方法是将当前对象作为锁,而 synchronized 代码块是将任意对象作为锁。
用关键字 synchronized 声明方法在某些情况下有弊端,例如线程 A 调用同步方法执行一个十分耗时的任务,那么线程 B 也一直等待,运行效率就不高,这种情况下,可以使用同步代码块来解决。
示例:创建一个任务类 Task,有一个同步方法,其中模拟执行耗时长的任务。

public class Task {

    private String getData1;
    private String getData2;

    public synchronized void doSomethingLongTime() {
        try {
            System.out.println("Task begin");
            // 模拟长时间处理任务
            TimeUnit.SECONDS.sleep(3);
            getData1 = Thread.currentThread().getName() + " -- LongTimeTask1 END";
            getData2 = Thread.currentThread().getName() + " -- LongTimeTask2 END";
            System.out.println(getData1);
            System.out.println(getData2);
            System.out.println("-------");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

自定义一个工具类 CommonTimeUtils,用于统计线程运行总耗时。

public class CommonTimeUtils {

    public static long beginTime1;
    public static long beginTime2;

    public static long endTime1;
    public static long endTime2;
}

自定义两个基本一致的线程类 TreadA 与 ThreadB,其中在 run() 中统计执行任务用时。

public class ThreadA extends Thread {
    private Task2 task;

    public ThreadA(Task2 task) {
        super();
        this.task = task;
    }

    @Override
    public void run() {
        super.run();
        CommonTimeUtils.beginTime1 = System.currentTimeMillis();
        task.doSomethingLongTime();
        CommonTimeUtils.endTime1 = System.currentTimeMillis();
    }
}

运行程序类:总耗时 6 秒。

public class SyncDemoTest2 {
    public static void main(String[] args) {
        Task2 task = new Task2();

        ThreadA threadA = new ThreadA(task);
        threadA.setName("线程A");
        threadA.start();

        ThreadB threadB = new ThreadB(task);
        threadB.setName("线程B");
        threadB.start();

        try {
            TimeUnit.SECONDS.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 统计总耗时
        long beginTime = CommonTimeUtils.beginTime1;
        if (CommonTimeUtils.beginTime2 < CommonTimeUtils.beginTime1){
            beginTime = CommonTimeUtils.beginTime2;
        }

        long endTime = CommonTimeUtils.endTime1;
        if (CommonTimeUtils.endTime2 > CommonTimeUtils.endTime1){
            endTime = CommonTimeUtils.endTime2;
        }
        System.out.println("总耗时:" + ((endTime - beginTime)/1000) + "秒");
    }
}

使用 synchronized 同步代码块解决这种情况:对 Task 类改进为 Task2 类,这样当一个线程访问 Task2 的 synchronized 同步代码块时,另一个线程可以访问 Task2 的非 synchronized(this) 同步代码块,所以能缩短运行时间。

public class Task2 {

    private String getData1;
    private String getData2;

    public void doSomethingLongTime() {
        try {
            System.out.println("Task begin");
            // 模拟长时间处理任务
            TimeUnit.SECONDS.sleep(3);
            String privateData1 = Thread.currentThread().getName() + " -- LongTimeTask1 END";
            String privateData2 = Thread.currentThread().getName() + " -- LongTimeTask2 END";
            // 对比 Task 的改动
            synchronized (this) {
                getData1 = privateData1;
                getData2 = privateData2;
                System.out.println(getData1);
                System.out.println(getData2);
                System.out.println("-------");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

wait/notify

任意一个 Java 对象,都拥有一个与之关联的唯一的监视器对象,为此 Java 为每个对象提供了一组监视器方法(定义在 java.lang.Object 上),主要包括 wait()、wait(long timeout)、notify() 以及 notifyAll() 方法,这些方法与 synchronized 同步关键字配合,可以实现等待/通知模式。
wait/notify 机制在生活中很常见,例如,你去麦当劳点餐时就会出现。你点餐后会有取餐码,但是厨房人员尚未制作好你点的炸鸡汉堡,所以服务员在等待状态(wait),厨房人员制作好了将食物打包放在取餐台上,然后通知(notify)服务员送给你。

wait() 方法使线程暂停运行,notify() 方法通知暂停的线程继续运行。拥有相同锁的线程才可以实现 wait/notify 机制。

生产者/消费者模式

wait/notify 模式最经典的案例就是生产者/消费者模式。
演示:创建一个资源类 DataObject。

public class DataObject {

    public int num = 0;

    public synchronized void increment() {
        if (num != 0){
            // 等待
            try {
                this.wait();
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        }
        // +1
        num++;
        // 通知其他线程
        System.out.println(Thread.currentThread().getName() + ":num = " + num);
        this.notify();
    }

    public synchronized void decrement() {
        if (num == 0){
            // 等待
            try {
                this.wait();
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        }
        // -1
        num--;
        System.out.println(Thread.currentThread().getName() + ":num = " + num);
        // 通知其他线程
        this.notify();
    }
}

一个生产者线程执行 increment() 方法,一个消费者线程执行 decrement() 方法。

public class PCTest3 {
    public static void main(String[] args) {
        DataObject data = new DataObject();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                data.increment();
            }
        }, "produce").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                data.decrement();
            }
        }, "consume").start();
    }
}

这样看起来没问题,但是一旦多新建 2 个生产者与消费者线程,就会出现“假死”问题,也就是全部的线程都进入了 waiting 状态,程序不能继续执行,但是没有结束。
假死状态
因为涉及到多生产与多消费,所以状态判断不能使用 if 语句,改为使用 while 语句。修改后,程序运行结束后会出现一句“Process finished with exit code 0”。
正常运行结束
但是这依然不是可靠的程序。在 main() 方法中先启动两个生产者线程,再启动两个消费者线程,不是一生产一消费交替启动。

public class PCTest3 {
    public static void main(String[] args) {
        DataObject data = new DataObject();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                data.increment();
            }
        }, "produce").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                data.increment();
            }
        }, "produce2").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                data.decrement();
            }
        }, "consume").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                data.decrement();
            }
        }, "consume2").start();
    }
}

运行结果:依然会出现“假死”问题。
第二种假死的原因
那为什么还会出现“假死”问题呢?因为 notify() 方法不保证唤醒的线程是“异类”,也可能是“同类”,也就是说,生产者线程唤醒的不一定是消费者线程,还可能是生产者线程,同样,消费者线程唤醒的也可能是消费者线程。例如,生产者线程 A 唤醒生产者线程 B,消费者线程 C 唤醒消费者线程 D,这种情况一旦连续发生,积少成多,就会导致所有的线程都无法继续运行,造成“假死”。
解决方法也不难,使用 notifyAll() 方法代替 notify() 方法即可,因为 notifyAll() 方法可以唤醒全部线程,那就包括了“异类”,所以程序正常运行。
使用notifyAll

Lock

使用关键字 synchronized 时可能存在以下问题:

  1. 一个线程持有锁会导致其他所有需要此锁的线程挂起,会造成线程的阻塞;
  2. 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,性能不好;
  3. 如果一个优先级高的线程等待一个优先级低的线程释放锁,会导致优先级倒置,引起性能问题。

    与 synchronized 的区别

  4. synchronized 是一个 Java 关键字,内置语言实现;而 Lock 是一个接口(java.util.concurrent.locks.Lock)。
  5. synchronized 会自动释放锁;而 Lock 需要在 finally 中必须释放锁,不然容易造成线程死锁。
  6. synchronized:假设 A 线程获得锁,B 线程等待。如果 A 线程阻塞,B 线程会一直等待;而 Lock 有多个锁获取的方式,可以尝试获得锁,线程可以不用一直等待(可以通过 tryLock() 判断有没有锁)。
  7. synchronized:可重入、不可中断、非公平;Lock:可重入、可判断、可公平(可自行设置)。
  8. synchronized 适用于少量同步;Lock 适用于大量同步。

    ReenTrantLock 类

    java.util.concurrent.locks 包提供的 ReentrantLock用于替代 synchronized加锁。
    JDK 1.5 新增的 ReentrantLock 类,可重入锁,它的构造方法有两个:一个是无参构造函数,一个是传入 fair 参数的构造函数。fair 参数代表的是锁的公平策略,如果传入 true 就表示需要构造一个公平锁,反之则表示要构造一个非公平锁。
    构造方法
    调用 ReentrantLock 对象的 Lock() 方法获取锁,调用 unlock() 方法释放锁,这两个方法成对使用。

    await/signal

    ReentrantLock 类也可以实现 wait/notify 机制,但需要借助 Condition 对象,而 Condition 对象需要通过 Lock 对象进行创建出来(调用 Lock 对象的 newCondition() 方法),Condition 对象的作用是控制并处理线程的状态。
    示例:使用 ReentrantLock 和 Condition 实现生产者/消费者模式。在 condition.await(); 方法调用之前必须调用 lock.lock() 代码获取锁。

    public class DataObject2 {
    
     public int num = 0;
    
     // Lock 类取代了 synchronized
     private Lock lock = new ReentrantLock();
    
     // Condition 类来代替 Object 监视器方法 wait、notify
     private Condition condition = lock.newCondition();
    
     public void increment() throws InterruptedException {
         // 上锁
         lock.lock();
         try {
             // 业务代码
             while (num != 0) {
                 // 等待
                 condition.await();
             }
             // +1
             num++;
             System.out.println(Thread.currentThread().getName() + ":num = " + num);
             // 通知其他线程
             condition.signalAll();
         } catch (InterruptedException e) {
             e.printStackTrace();
         } finally {
             // 解锁
             lock.unlock();
         }
     }
    
     public void decrement() throws InterruptedException {
         lock.lock();
         try {
             while (num == 0) {
                 // 等待
                 condition.await();
             }
             // -1
             num--;
             System.out.println(Thread.currentThread().getName() + ":num = " + num);
             // 通知其他线程
             condition.signalAll();
         } catch (InterruptedException e) {
             e.printStackTrace();
         } finally {
             lock.unlock();
         }
     }
    }

    对应关系:

  9. Object 类中的 wait() 方法相当于 Condition 类中的 await() 方法。
  10. Object 类中的 notify() 方法相当于 Condition 类中的 signal() 方法。
  11. Object 类中的 notifyAll() 方法相当于 Condition 类中的 signalAll() 方法。

在使用 notify() 方法时,被通知的线程由 JVM 进行选择,而方法 notifyAll() 会通知所有的等待线程,没有选择权,效率问题较大。而在一个 Lock 对象中可以创建多个 Condition 实例,线程对象注册在指定的 Condition 中,可以实现精准的“选择性通知”,例如,线程 A 执行完毕后通知线程 B,线程 B 执行完毕后通知线程 C。

public class DataObject3 {
    // 标志变量
    public int num = 1;

    private Lock lock = new ReentrantLock();

    // Condition 可以有多个
    private Condition condition1 = lock.newCondition();
    private Condition condition2 = lock.newCondition();
    private Condition condition3 = lock.newCondition();

    public void printA() {
        lock.lock();
        try {
            // 条件判断
            while(num != 1){
                // 线程等待
                condition1.await();
            }
            System.out.println(Thread.currentThread().getName() + "==> printA()");
            // 唤醒指定的
            num = 2;
            condition2.signal();
        }catch (Exception e) {
            e.printStackTrace();
        }finally {
            // 解锁
            lock.unlock();
        }
    }

    public void printB() {
        lock.lock();
        try {
            while(num != 2){
                // 线程等待
                condition2.await();
            }
            System.out.println(Thread.currentThread().getName() + "==> printB()");
            // 唤醒指定的
            num = 3;
            condition3.signal();
        }catch (Exception e) {
            e.printStackTrace();
        }finally {
            // 解锁
            lock.unlock();
        }
    }

    public void printC() {
        lock.lock();
        try {
            while(num != 3){
                // 线程等待
                condition3.await();
            }
            System.out.println(Thread.currentThread().getName() + "==> printC()");
            // 唤醒指定的
            num = 1;
            condition1.signal();
        }catch (Exception e) {
            e.printStackTrace();
        }finally {
            // 解锁
            lock.unlock();
        }
    }
}

可以用这种做法通过 leetcode 的 1114.按顺序打印(虽然效率不高)。

参考

Java全栈知识体系 - ReentrantLock
敖丙 - 可重入锁
打赏
评论区
头像
    头像
    789
    2022年03月16日 02:10
    回复

    大哥,为什么同样主题你的那么炫酷

      头像
      New
      2022年03月16日 22:31
      回复

      自己修改一下就行

文章目录