Java并发编程--多线程间的同步控制和通信

·  阅读 908

本文为掘金社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

使用多线程并发处理,目的是为了让程序更充分地利用CPU ,好能加快程序的处理速度和用户体验。如果每个线程各自处理的部分互不相干,那真是极好的,我们在程序主线程要做的同步控制最多也就是等待几个工作线程的执行完毕,如果不 Care 结果的话,连同步等待都能省去,主线程撒开手让这些线程干就行了。

不过,现实还是很残酷的,大部分情况下,多个线程是会有竞争操作同一个对象的情况的,这个时候就会导致并发常见的一个问题--数据竞争(Data Racing)。

这篇文章我们就来讨论一下这个并发导致的问题,以及多线程间进行同步控制和通信的知识,本文大纲如下:

并发导致的Data Racing问题

怎么理解这个问题呢,拿一个多个线程同时对累加器对象进行累加的例子来解释吧。

package com.learnthread;

public class DataRacingTest {
    
    public static void main(String[] args) throws InterruptedException {
        final DataRacingTest test = new DataRacingTest();
        // 创建两个线程,执行 add100000() 操作
        // 创建Thread 实例时的 Runnable 接口实现,这里直接使用了 Lambda
        Thread th1 = new Thread(()-> test.add100000());
        Thread th2 = new Thread(()-> test.add100000());
        // 启动两个线程
        th1.start();
        th2.start();
        // 等待两个线程执行结束
        th1.join();
        th2.join();
        System.out.println(test.count);
    }

    private long count = 0;

    // 想复现 Data Racing,去掉这里的 synchronized
    private  void add100000() {
        int idx = 0;
        while(idx++ < 100000) {
            count += 1;
        }
    }
}
复制代码

上面这个例程,如果我们不启动 th2 线程,只用 th1 一个线程进行累加操作的话结果是 100000。按照这个思维,如果我们启动两个线程那么最后累加的结果就应该是 200000。 但实际上并不是,我们运行一下上面的例程,得到的结果是:

168404

Process finished with exit code 0
复制代码

当然这个在每个人的机器上的结果是不一样的,而且也是有可能恰好等于 200000,需要多运行几次,或者是多开几个线程执行累加,出现 Data Racing 的几率才高。

程序出现 Data Racing 的现象,就意味着最终拿到的数据是不正确的。那么为了避免这个问题就需要通过加锁来解决了,让同一时间只有持有锁的线程才能对数据对象进行操作。当然针对简单的运算、赋值等操作我们也能直接使用原子操作实现无锁解决 Data Racing, 我们为了示例足够简单易懂才举了一个累加的例子,实际上如果是一段业务逻辑操作的话,就只能使用加锁来保证不会出现 Data Racing了。

加锁,只是线程并发同步控制的一种,还有释放锁、唤醒线程、同步等待线程执行完毕等操作,下面我们会逐一进行学习。

同步控制--synchronized

开头的那个例程,如果想避免 Data Racing,那么就需要加上同步锁,让同一个时间只能有一个线程操作数据对象。 针对我们的例程,我们只需要在 add100000 方法的声明中加上 synchronized 即可。

    // 想复现 Data Racing,去掉这里的 synchronized
    private synchronized void add100000() {
        int idx = 0;
        while(idx++ < 100000) {
            count += 1;
        }
    }
复制代码

是不是很简单,当然 synchronized 的用法远不止这个,它可以加在实例方法、静态方法、代码块上,如果使用的不对,就不能正确地给需要同步锁保护的对象加上锁。

synchronized 是 Java 中的关键字,是利用锁的机制来实现互斥同步的。 synchronized 可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块。 如果不需要 Lock 、读写锁ReadWriteLock 所提供的高级同步特性,应该优先考虑使用synchronized 这种方式加锁,主要原因如下:

  • Java 自 1.6 版本以后,对 synchronized 做了大量的优化,其性能已经与 JUC 包中的 LockReadWriteLock 基本上持平。从趋势来看,Java 未来仍将继续优化 synchronized ,而不是 ReentrantLock 。
  • ReentrantLock 是 Oracle JDK 的 API,在其他版本的 JDK 中不一定支持;而 synchronized 是 JVM 的内置特性,所有 JDK 版本都提供支持。

synchronized 可以应用在实例方法、静态方法和代码块上:

  • synchronized 关键字修饰实例方法,即为同步实例方法,锁是当前的实例对象。
  • synchronized 关键字修饰类的静态方法,即为同步静态方法,锁是当前的类的 Class 对象。
  • 如果把 synchronized 应用在代码块上,锁是 synchronized 括号里配置的对象,synchronized(this) {..} 锁就是代码块所在实例的对象,synchronized(类名.class) {...} ,锁就是类的 Class 对象。

同步实例方法和代码块

上面我们已经看过怎么给实例方法加 synchronized 让它变成同步方法了。下面我们看一下,synchronized 给实例方法加锁时,不能保证资源被同步锁保护的例子。

class Account {
  private int balance;
  // 转账
  synchronized void transfer(Account target, int amt){
    if (this.balance > amt) {
      this.balance -= amt;
      target.balance += amt;
    }
  }
}
复制代码

在这段代码中,临界区内有两个资源,分别是转出账户的余额 this.balance 和转入账户的余额 target.balance,并且用的是一把实例对象的锁。问题就出在 this 这把锁上,this 这把锁可以保护自己的余额 this.balance,却保护不了别人的余额 target.balance,就像你不能用自家的锁来保护别人家的资产一个道理。

应该保证使用的锁能保护所有应受保护资源。我们可以使用Account.class 作为加锁的对象Account.class 是所有 Account 类的对象共享的,而且是 Java 虚拟机在加载 Account 类的时候创建的,保证了它的全局唯一性。

class Account {
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    synchronized(Account.class) {
      if (this.balance > amt) {
        this.balance -= amt;
        target.balance += amt;
      }
    }
  }
}
复制代码

用 synchronized 给 Account.class 加锁,这样就保证出账、入账两个 Account 对象在同步代码块里都能收到保护。

当然我们也可以使用这笔转账的交易对象作为加锁的对象,保证只有这比交易的两个 Account 对象受保护,这样就不会影响到其他转账交易里的出账、入账 Account 对象了。

class Account {
  private Trans trans;
  private int balance;
  private Account();
  // 创建 Account 时传入同一个 交易对象作为 lock 对象
  public Account(Trans trans) {
    this.trans = trans;
  }
  // 转账
  void transfer(Account target, int amt){
    // 此处检查所有对象共享的锁
    synchronized(trans) {
      if (this.balance > amt) {
        this.balance -= amt;
        target.balance += amt;
      }
    }
  }
}
复制代码

通过解决上面这个问题我们顺道就把 synchronized 修饰同步代码块的知识点学了, 现在我们来看 synchronized 的最后一个用法--修饰同步静态方法。

同步静态方法

静态方法的同步是指,用 synchronized 修饰的静态方法,与使用所在类的 Class 对象实现的同步代码块,效果类似。因为在 JVM 中一个类只能对应一个类的 Class 对象,所以同时只允许一个线程执行同一个类中的静态同步方法。

对于同一个类中的多个静态同步方法,持有锁的线程可以执行每个类中的静态同步方法而无需等待。不管类中的哪个静态同步方法被调用,一个类只能由一个线程同时执行。

package com.learnthread;

public class SynchronizedStatic implements Runnable {

    private static final int MAX = 100000;

    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        SynchronizedStatic instance = new SynchronizedStatic();
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
        // 等待工作线程执行结束
        t1.join();
        t2.join();
        System.out.println(count);
    }

    @Override
    public void run() {
        for (int i = 0; i < MAX; i++) {
            increase();
        }
    }

    /**
     * synchronized 修饰静态方法
     */
    public synchronized static void increase() {
        count++;
    }

}

复制代码

线程挂起和唤醒

上面我们看了使用 synchronized 给对象加同步锁,让同一时间只有一个线程能操作临界区的控制。接下来,我们看一下线程的挂起和唤醒,这两个操作使用被线程成功加锁的对象的 waitnotify 方法来完成,唤醒除了notify 外还有 notifyAll方法用来唤醒所有线程。下面我们先看一下这几个方法的解释。

  • wait - wait 会自动释放当前线程占有的对象锁,并请求操作系统挂起当前线程,让线程从 Running 状态转入 Waiting 状态,等待被 notify / notifyAll 来唤醒。如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步代码块中,那么就无法执行 notify 或者 notifyAll 来唤醒挂起的线程,会造成死锁。
  • notify - 唤醒一个正在 Waiting 状态的线程,并让它拿到对象锁,具体唤醒哪一个线程由 JVM 控制 。
  • notifyAll - 唤醒所有正在 Waiting 状态的线程,接下来它们需要竞争对象锁。

这里有两点需要各位注意的地方, 第一个是 waitnotifynotifyAll 都是 Object 类中的方法,而不是 Thread 类的。 image.png 因为 Object 是始祖类,是不是意味着所有类的对象都能调用这几个方法呢?是,也不是... 因为 wait、notify、notifyAll 只能用在 synchronized 方法或者 synchronized 代码块中使用,否则会在运行时抛出 IllegalMonitorStateException。换句话说,只有被 synchronized 加上锁的对象,才能调用这三个方法。

为什么 waitnotifynotifyAll 不定义在 Thread 类中?为什么 waitnotifynotifyAll 要配合 synchronized 使用? 理解为什么这么设计,需要了解几个基本知识点:

  • 每一个 Java 对象都有一个与之对应的监视器(monitor)
  • 每一个监视器里面都有一个 对象锁 、一个 等待队列、一个 同步队列

了解了以上概念,我们回过头来理解前面两个问题。

为什么这几个方法不定义在 Thread 中?

  • 由于每个对象都拥有对象锁,让当前线程等待某个对象锁,自然应该基于这个对象(Object)来操作,而非使用当前线程(Thread)来操作。因为当前线程可能会等待多个线程释放锁,如果基于线程(Thread)来操作,就非常复杂了。

为什么 wait、notify、notifyAll 要配合 synchronized 使用?

  • 如果调用某个对象的 wait 方法,当前线程必须拥有这个对象的对象锁,因此调用 wait 方法必须在 synchronized 方法和 synchronized 代码块中。

下面看一个 wait、notify、notifyAll 的一个经典使用案例,实现一个生产者、消费者模式:

package com.learnthread;

import java.util.PriorityQueue;

public class ThreadWaitNotifyDemo {

    private static final int QUEUE_SIZE = 10;
    private static final PriorityQueue<Integer> queue = new PriorityQueue<>(QUEUE_SIZE);

    public static void main(String[] args) {
        new Producer("生产者A").start();
        new Producer("生产者B").start();
        new Consumer("消费者A").start();
        new Consumer("消费者B").start();
    }

    static class Consumer extends Thread {

        Consumer(String name) {
            super(name);
        }

        @Override
        public void run() {
            while (true) {
                synchronized (queue) {
                    while (queue.size() == 0) {
                        try {
                            System.out.println("队列空,等待数据");
                            queue.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                            queue.notifyAll();
                        }
                    }
                    queue.poll(); // 每次移走队首元素
                    queue.notifyAll();
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + " 从队列取走一个元素,队列当前有:" + queue.size() + "个元素");
                }
            }
        }
    }

    static class Producer extends Thread {

        Producer(String name) {
            super(name);
        }

        @Override
        public void run() {
            while (true) {
                synchronized (queue) {
                    while (queue.size() == QUEUE_SIZE) {
                        try {
                            System.out.println("队列满,等待有空余空间");
                            queue.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                            queue.notifyAll();
                        }
                    }
                    queue.offer(1); // 每次插入一个元素
                    queue.notifyAll();
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + " 向队列取中插入一个元素,队列当前有:" + queue.size() + "个元素");
                }
            }
        }
    }
}
复制代码

上面的例程有两个生产者和两个消费者。生产者向队列中放数据,每次向队列中放入数据后使用 notifyAll 唤醒消费者线程,当队列满后生产者会 wait 让出线程,等待消费者取走数据后再被唤醒 (消费者取数据后也会调用 notifyAll )。同理消费者在队列空后也会使用 wait 让出线程,等待生产者向队列中放入数据后被唤醒。

线程等待--join

waitnotify 方法一样,join 是另一种线程间同步机制。当我们调用线程对象 join 方法时,调用线程会进入等待状态,它会一直处于等待状态,直到被引用的线程执行结束。在上面的几个例子中,我们已经使用过了 join 方法

   ...
	public static void main(String[] args) throws InterruptedException {
        SynchronizedStatic instance = new SynchronizedStatic();
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
        // 等待工作线程执行结束
        t1.join();
        t2.join();
        System.out.println(count);
    }
复制代码

这个例子里,主线程调用 t1 和 t2 的 join 方法后,就会一直等待,直到他们两个执行结束。如果 t1 或者 t2 线程处理时间过长,调用它们 join 方法的主线程将一直等待,程序阻塞住。为了避免这些情况,可以使用能指定超时时间的重载版本的 join 方法。

    t2.join(1000); // 最长等待1s
复制代码

如果引用的线程被中断,join方法也会返回。在这种情况下,还会触发 InterruptedException。所以上面的main方法为了演示方便,直接选择抛出了 InterruptedException

总结

同步控制的一大思路就是加锁,除了本问学习到的 sychronized 同步控制,Java 里还有 JUC 的可重入锁、读写锁这种加锁方式,这个我们后续介绍 JUC 的时候会给大家讲解。

另外一种思路是不加锁,让线程和线程之间尽量不要使用共享数据,ThreadLocal 就是这种思路,下篇我们介绍 Java 的线程本地存储 -- ThreadLocal。

分类:
后端
收藏成功!
已添加到「」, 点击更改