Java 线程和 volatile 解释

1,550 阅读6分钟

最近开始学习 Java,所以记录一些 Java 的知识点。这篇是一些关于 Java 线程的文章。

Java 支持多线程,Java 中创建线程的方式有两种:

  • 继承 Thread 类,重写 run 方法。
  • 实现 Runnable 接口,实现 run 方法。

// 继承 Thread 类
class ThreadDemo extends Thread {

    @Override
    public void run() {
        System.out.println("一个简单的例子就需要这么多代码...");
    }
}



// 实现 Runnable 接口
class RunnableDemo implements Runnable {
    public void run() {
        System.out.println("一个简单的例子就需要这么多代码...");
    }
}


public class Main {
    public static void main(String[] strings) {

        // 继承 Thread 类
        Thread thread = new ThreadDemo();
        thread.start();

        // 实现 Runnable 接口
        Thread again = new Thread(new RunnableDemo());
        again.start();
    }
}

通过调用 start 函数可以启动有一个新的线程,并且执行 run 方法中的逻辑。这里可以引出一个很容易被问道的面试题:

Thread 类中 start 函数和 run 函数有什么区别。

最明显的区别在于,直接调用 run 方法并不会启动一个新的线程来执行,而是调用 run 方法的线程直接执行。只有调用 start 方法才会启动一个新的线程来执行。

引入线程的目的是为了使得多个线程可以在多个 CPU 上同时运行,提高多核 CPU 的利用率。

多线程编程很常见的情况下是希望多个线程共享资源,通过多个线程同时消费资源来提高效率,但是新手一不小心很容易陷入一个编码误区。

class ThreadDemo extends Thread {
    private int i = 3;
    @Override
    public void run() {
        i--;
        System.out.println(i);
    }
}

public class Main {
    public static void main(String[] strings) {
        Thread thread = new ThreadDemo();
        thread.start();
        Thread thread1 = new ThreadDemo();
        thread1.start();
        Thread thread2 = new ThreadDemo();
        thread2.start();
    }
}

上面的实例代码,希望通过 3 个线程同时执行 i--; 操作,使得最终 i 的值为 0,但是结果不如人意,3 次输出的结果都为 2。这是因为在 main 方法中创建的三个线程都独自持有一个 i ,我们的目的一应该是 3 个线程共享一个 i。

public class Main {
    public static void main(String[] strings) {
        DemoRunnable demoRunnable = new DemoRunnable();
        new Thread(demoRunnable).start();
        new Thread(demoRunnable).start();
        new Thread(demoRunnable).start();
    }
}

class DemoRunnable implements Runnable {

    private int i= 3;

    @Override
    public void run() {
        i--;
        System.out.println(i);
    }
}

使用上面的代码才有可能使得 i 最终的结果为0。所以,在进行多线程编程的时候一定要留心多个线程是否共享资源。

Volatile

如果你运气好,执行上面的代码发现,有时候三次 i--; 的结果也不一定是 0。这种怪异的现象需要从 JVM 的内存模型说起。

当 Java 启动了多个线程分布在不同的 CPU 上执行逻辑,JVM 为了提高性能,会把在内存中的数据拷贝一份到 CPU 的寄存器中,使得 CPU 读取数据更快。很明显,这种提高性能的做法会使得 Thread1 中对 i 的修改不能马上反应到 Thread2 中。

下面例子可以明显的体现出这个问题。

public class Main {
    static int NEXT_IN_LINE = 0;

    public static void main(String[] args) throws Exception {
        new ThreadA().start();
        new ThreadB().start();
    }

    static class ThreadA extends Thread {
        @Override
        public void run() {
            while (true) {
                if (NEXT_IN_LINE >= 4) {
                    break;
                }
            }
            System.out.println("in CustomerInLine...." + NEXT_IN_LINE);
        }
    }

    static class ThreadB extends Thread {
        @Override
        public void run() {
            while (NEXT_IN_LINE < 10) {
                System.out.println("in Queue ..." + NEXT_IN_LINE++);
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

上面的代码中,ThreadA 线程进入死循环一直到 NEXT_IN_LINE 的值为 4 才退出,ThreadB 线程不停的对 NEXT_IN_LINE++ 操作。然而执行代码发现 ThreadA 没有输出 in CustomerInLine...." + NEXT_IN_LINE,而是一直处于死循环状态。这个例子可以很明显的验证:"JVM 会把线程共享的变量拷贝到寄存器中以提高效率" 的说法。

那么,怎么才能避免这种优化给编程带来的困扰?这里要引出一个内存可见性 的概念。

内存可见性指的是一个线程对共享变量值的修改,能够及时地被其他线程看到。

为了实现内存可见性,Java 引入了 volatile 的关键字。这个关键字的作用在于,当使用 volatile 修改了某个变量,那么 JVM 就不会对该变量进行优化,即意味着,不会把该变量拷贝到 CPU 寄存器中,每个变量对该变量的修改,都会实时的反应在内存中。

针对上面的例子,把 static int NEXT_IN_LINE = 0; 改成 static volatile int NEXT_IN_LINE = 0; 那么执行的结果就如我们所预料的,在 ThraedB 自增到 NEXT_IN_LINE = 4 的时候 ThreadA 会跳出死循环。

指令重排

volatile 还有一个很好玩的特性:防止指令重排。

首先要明白什么是指令重排?

假设在 ThreadA 中有

context = loadContext();
inited = true;

ThreadB 中

while(!inited) {
    sleep(100);
}
doSomething(context);

那么,ThreadB 中会在 inited 置位 true 之后执行 doSomething 方法,inited 变量的作用就是用来标志 context 是否被初始化了。但是实际上在执行 ThreadA 代码的时候 JVM 会根据上下行代码是否互相关联而决定是否对代码执行顺序进行重排。这就意味着 CPU 认为 ThreadA 中的两行代码没有顺序关联,于是先执行 inited=true 再执行 context=loadContext()。如此一来,就会导致 ThreadB 中引用了一个值为 null 的 context 对象。

使用 volatile 可以避免指令重排。在定义 inited 变量的时候使用 volatile修饰:volatile boolean inited = false;。 使用 volatile 修饰 inited 之后,JVM 就不会对 inited 相关的变量进行指令重排。

原子性

回到最初的例子。在 volatile 部分我们说过最终的结果不是输出 i = 0 的原因是 JVM 拷贝内存变量到 CPU 寄存器中导致线程之间没办法实时更新 i 变量的值导致的,只要使用 volatile 修饰 i 就可以实现内存可见性,可以使得结果输出 i = 0。但是实际上,即使使用了 volatile 之后,还是有可能的导致 i != 0 的结果。

输出 i != 0 的结果是由于 i++; 操作并非为原子性操作。

什么是原子性操作?简单来说就是一个操作不能再分解。i++ 操作实际上分为 3 步:

  • 读取 i 变量的值。
  • 增加 i 变量的值。
  • 把新的值写到内存中。

那么,假设 ThraedA 在执行第 2 步之后,ThreadB 读取了 i 变量的值,这时候还未被 ThreadA 更新,读取的仍是旧的值,之后 ThreadA 写入了新的值。这种情况下就会导致 i 在某个时刻被修改多次。

解决这种问题需要用到 synchronized。但是这里不打算对 synchronized 进行讨论。这里指出一个很容易被误解的概念:volatile 能够实现内存可见性和避免指令重排,但是不能实现原子性。