Java并发核心要点讲解

342 阅读9分钟

理论

Java多线程编程是Java中非常重要的一部分,要写出高效且安全的多线程并发程序,需要开发者对操作系统底层的原理有深刻的理解。

共享性

在单线程下,数据修改不存在线程安全性问题。一旦到了多线程环境下,多个线程需要共享同一个数据,这就需要考虑数据安全问题了。

共享性是多线程带来的问题,也是需要被解决的问题。目的是达到同一份数据同时被多个线程共享(操作),也能保证数据安全。

以下的几个特性都是为了保证这种数据安全而提出的特性。

互斥性

正如上述,如果多个线程同时修改一个数据,必然导致数据安全问题。那么我们可以提出一种资源互斥的策略,让同一个资源同时只能被一个线程访问。Java中最简单的实现是使用synchronized关键字。

原子性

原子性即一个操作是最小的、不可再分割的整体。保证原子性最简单的方式是使用操作系统指令例如CAS

但例如long型运算,许多操作系统将其分为高位与低位运算,不满足原子性;i++操作实际上也不是一个原子性操作 (分为读、写、更新内存)。在Java中我们可以使用atomic原子包下的封装类来实现。

可见性

理解可见性需要先理解JVM内存模型,如下,

从图中可知,JVM中每个线程都拥有一个对应的工作线程 (目的为了缩小存储模块与CPU处理速度的差异,提高性能)。对于共享变量来说,线程每次先读取的是工作内存中共享变量的副本,写入的时候同样也是直接修改副本的值,之后在某个时间点上再将工作内存中的值同步到主内存上。

可见这样导致的问题是,线程1对变量进行了修改,可能此时线程2看不到线程1对其的修改,会产生线程2使用旧值的情况。在一些即时性场景下会出现问题。

public class Test {
    private static boolean done;
    private static int result;

    private static class ReaderThread extends Thread {
        public void run() {
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if (!done) {
                System.out.println(done);
            }
            System.out.println(result);
        }
    }

    private static class WriterThread extends Thread {
        public void run() {
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            result = 100;
            done = true;
        }
    }

    public static void main(String[] args) {
        new WriterThread().start();
        new ReaderThread().start();
    }
}

运行几次,可能的结果如下,

//第一次运行
true
100

//第二次运行
false
0

第一次运行:

在执行if (!done)还没有读取到写线程的结果,但执行下面的println(done)时又获取到了写线程的结果。

第二次运行:

当读线程println(result)时还没有获取到写线程更新的值100。

在Java中可以通过使用关键字volatile来保证可见性。

有序性

为了提高程序执行的性能,编译器、CPU可能会对指令做重排序,

  1. 编译器优化的重排序
  2. 指令级并行重排序,现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

线程协作

线程主要状态

线程状态的切换可以用一张图概括,

其中,

  • New: 新建状态,即new Thread,未调用start前。
  • Runnable: 就绪状态,调用start方法,线程进入就绪状态,等待CPU资源。处于就绪状态的线程由Java运行时系统的线程调度程序(thread scheduler)来调度。
  • Running: 运行状态
  • Blocked: 阻塞状态,线程未执行完,由于某些原因让出CPU时间片,自身进入阻塞。
  • Dead: 死亡状态,线程执行完成或出现异常。

wait、notify、notifyAll

!! 因为他们的实现是通过对象的monitor所有权来实现的,Java中只能通过关键字synchronized来完成。即调用这几个方法必须得在同步方法、方法块中。

wait方法将当前线程挂起,并且让出monitor所有权,直到有notify/notifyAll或是超时来唤醒线程。

notify/notifyAll是在同一对象上被调用,则可以唤醒对应monitor上等待的线程。而他们两者的区别在于,

  1. notify只能唤醒monitor上的一个线程,对其他线程没有影响
  2. notifyAll则会唤醒所有的线程

await、 signal、 signalAll

JUC (java.util.concurrent)中提供了Condition类来实现线程之间的协调。可以在 Condition 上调用 await() 方法使线程等待,其它线程调用 signal()signalAll() 方法唤醒等待的线程。

因为Condition可以被初始化很多个,每一个可以成为一个单独的wait-notify条件,因此这种方式便更为灵活。

设想一个例子,我们需要创建一个有边界的缓冲区,其中有两个方法put和take,

  1. put:当缓冲区已经满了时,线程将会阻塞直到有可用空间为止
  2. take:当获取的缓冲区为空时,线程将会阻塞直到有可用数据为止

实现的诉求是能够将put线程和take线程的等待项分离开来,以便可以优化成当上述条件满足时,仅唤醒单个对应线程。那么这种需求可以使用两个Condition实例来实现,

class BoundedBuffer<T> {
    private final Lock lock = new ReentrantLock();
    
    //未满条件实例
    private final Condition notFull  = lock.newCondition();
    //未空条件实例
    private final Condition notEmpty = lock.newCondition();

    private final Object[] items = new Object[5];
    private int putptr, takeptr, count;

    public void put(T x) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length)
                notFull.await();
            items[putptr] = x;
            if (++putptr == items.length) putptr = 0;
            ++count;
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }

    @SuppressWarnings("unchecked")
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0)
                notEmpty.await();
            T x = (T) items[takeptr];
            if (++takeptr == items.length) takeptr = 0;
            --count;
            notFull.signal();
            return x;
        } finally {
            lock.unlock();
        }
    }
}

测试代码如下,

class Test {
    public static void main(String[] args) {
        final BoundedBuffer<Integer> boundedBuffer = new BoundedBuffer<Integer>();
        new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 10; i++) {
                    try {
                        boundedBuffer.put(i);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();
        new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 10; i++) {
                    try {
                        System.out.print(boundedBuffer.take() + " ");
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();
    }
}


输出结果:
0 1 2 3 4 5 6 7 8 9 

从输出结果来看,是满足需求的。可见使用Condition进行线程间协调,有着更灵活,效率更高的好处。

sleep、yield、join

sleep让当前线程暂停指定的时间,与wait的区别,

  1. wait需要同步,即在关键字synchronized下完成,而sleep可以直接被调用
  2. sleep暂时让出CPU时间片,不释放锁,wait会释放锁

yield方法的作用是暂停当前线程,以便其他线程有机会执行,不能保证当前线程马上停止。yield方法只是将Running状态转变为Runnable状态。很少有场景用到该方法,主要是用来测试和调试。

join方法的作用是父线程等待子线程执行完成后再执行,即将异步执行的线程合并为同步执行线程。join也是通过wait/notify来实现的。

synchronized原理

我们通过对以下代码反编译来看看synchronized是怎么实现的,

class Test {
    public void method() {
        synchronized (this) {
            System.out.println("start");
        }
    }
}

反编译通过java提供的工具javap来实现,具体输出信息如下,

->: javap -c Test

Compiled from "Test.java"
class com.dv.Test {
  com.dv.Test();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void method();
    Code:
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter //关键点
       4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       7: ldc           #3                  // String start
       9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      12: aload_1
      13: monitorexit //关键点
      14: goto          22
      17: astore_2
      18: aload_1
      19: monitorexit //关键点
      20: aload_2
      21: athrow
      22: return
    Exception table:
       from    to  target type
           4    14    17   any
          17    20    17   any
}

查看以上反编译出的JVM指令信息可知,monitorentermonitorexit是实现synchronized关键字的核心。

JVM中对monitor有规定,每个对象都有一个monitor。当monitor被占用时就会处于锁定状态,执行monitorenter指令会尝试获取monitor所有权,

  1. 内部有个对monitorenter的统计数,如果此时monitorenter进入数为0,则该线程获得monitor所有权,并将其置为1.
  2. 如果线程已拥有monitor所有权,重新进入会将统计数+1.
  3. 如果monitor被其他线程占用了,则当前线程进入阻塞状态,直到monitorenter的统计数为0,再次尝试获取monitor所有权。

monitorexitmonitorenter的反向逻辑,道理相同,每次执行该指令时将统计数减1,直到为0,释放monitor所有权.

volatile原理

如上述讲解的,多线程情况下操作共享变量可能会因变量没有线程间可见性而导致数据不安全。这时就可以靠关键字volatile来解决。

另一方面volatile也可以防止重排序。我们就来看看volatile的底层原理。

可见性实现

对volatile变量的写操作和普通变量的实现不同,主要区别如下,

  1. 修改volatile变量时会将其强制刷新到主存中
  2. 修改volatile变量后,会使得其他线程中工作内存对应的变量值副本失效,从而再次读取变量时会从主存中读取

防止重排序实现

这个其实就是JSR中规范中的一个概念,happens-before,即对volatile变量的写操作发生在读操作之前(写操作对读操作可见)。

具体的实现是在Java内存模型中实现的,Java内存模会针对定义了volatile变量的值,限制其重排序。

内存屏障

为了实现volatile可见性和happens-before,JVM通过内存屏障来完成。

内存屏障,也叫做内存栅栏,是一组处理器指令,它确保指令重排序不会把内存屏障后的指令排到它前面执行,即在执行到内存屏障指令时,它前面的操作已经全部完成。