一、Java内存模型(JMM)

JMM 即 Java Memory Model(java内存模型),它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。

  • 不同线程之间无法直接访问对方工作内存中的变量,线程间的通信一般有两种方式进行,一是通过消息传递,二是共享内存。Java 线程间的通信采用的是共享内存方式

  • JMM规定了所有的变量都必须存在主内存中, 每条线程都有自己的工作内存。 对变量进行操作会对线程进行加锁,加锁前必须读取主内存的最新值到自己的工作内存,然后在自己的工作内存中进行, 不能直接去读写主内存中的变量(这里的工作内存是 JMM 的一个抽象概念,也叫本地内存,其存储了该线程以读 / 写共享变量的副本。),操作完成后线程释放锁,解锁前必须把共享变量的值刷新回主内存。

Java内存模型在Java1.5时被重新修订,这个版本的Java内存模型在Java8中仍然在使用。

二、 使用多线程的原因

现在的CPU都是多核的, CPU , 内存 , I/O设备速度存在差异, CPU>内存>I/O。采用多线程的方式, 可以提高程序的响应速度, 榨取硬件的剩余价值(提高CPU利用率),为了合理利用CPU 的高性能,平衡这三者的速度差异,做了如下优化 :

  1. CPU 增加了缓存,以均衡与内存的速度差异;// 导致 可见性问题
  2. 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU 与I/O 设备的速度差异;// 导致 原子性问题
  3. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。// 导致 有序性问题

三、 使用多线程的并发问题

1. 可见性问题

多线程的可见性问题指的是一个线程对共享变量的修改可能不会被其他线程立即看到,从而导致数据的不一致性。例如:主线程 对 flag 的修改对于t1线程不可见,导致了 t1线程 获取到的变量还是修改前的值,导致t1线程无法停止。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import java.util.concurrent.TimeUnit;

public class Test{
    private static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            while (flag) {

            }
        },"线程");
        t1.start();

        TimeUnit.SECONDS.sleep(1);

        flag = false;
        System.out.println("主线程停止");
    }
}

原因分析: 当程序启动首先执行t1线程t1线程会把flag的变量加载到自己工作内存中,然后主线程修改了flag的值并同步至主存,而t1线程工作内存缓存中的flag并没有同步更新,结果还是旧的值,所以t1线程无法停止。

解决办法: 我们可以使用 volatile 关键字来修饰 flag,它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存

1
private static volatile boolean flag = true;

还可以使用synchronized 来保证 flag 的可见性

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import java.util.concurrent.TimeUnit;

public class Test{
    private static boolean flag = true;
    private static final Object LOCK = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (true) {
                synchronized (LOCK) {
                    if (!flag) {
                        break;
                    }
                }
            }
        });
        t.start();
        TimeUnit.SECONDS.sleep(1);
        synchronized (LOCK) {
            flag = false;
        }
        Syst.out.println("主线程停止");
    }
}

2. 原子性问题

多线程的原子性问题指的是在并发环境中,某些操作可能被中断,导致数据状态不一致,从而无法保证操作的完整性。例如:两个线程同时对一个Integer变量进行操作,两个各加1000次,最后结果应该是2000,但是运行结果可能小于2000与预期不一样的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Test{
    private static Integer count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(){
            public void run(){
                for (int i = 0;i<1000;i++) {
                    count += 1;
                }
            }
        };
        Thread t2 = new Thread(){
            public void run(){
                for (int i = 0;i<1000;i++) {
                    count += 1;
                }
            }
        };
        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(count);
    }
}

运行结果:

1
1293

原因分析: count += 1;这一行语句进行了三条指令:

  1. 将变量 count从主存读取到线程工作内存中;

  2. 线程在工作内存中执行count add操作;

  3. 将最后的结果count写入主存(缓存机制导致可能写入的是CPU 缓存而不是内存)。

当两个线程 t1 和 t2 同时执行这个操作时,可能出现以下情况:当 t1获得执行资格, t1从主内存读取到count,并将count加到100,此时由于CPU分时复用(线程切换)的存在,执行资格切换到线程t2t1未来得及将修改的值写回主存),此时t2从主内存读取count值仍为0,然后t2继续执行++操作。接着资源又再切换会 t1执行写入主存的操作。最终当 t1t2执行完毕写回主存的值与预期不符。

解决办法: 为保证程序的正确执行必须保证一组指令以原子方式执行:即某一个线程执行时,其他线程必须等待,可以使用 synchronized 或者 ReentrantLock

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class Test{
    public static final Object lock = new Object();
    private static Integer count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(){
            public void run(){
                for (int i = 0;i<1000;i++) {
                    synchronized (lock) {
                        count += 1;
                    }
                }
            }
        };
        Thread t2 = new Thread(){
            public void run(){
                for (int i = 0;i<1000;i++) {
                    synchronized (lock) {
                        count += 1;
                    }
                }
            }
        };
        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(count);
    }
}

它表示用lock实例作为锁,两个线程在执行各自的synchronized(lock) { ... }代码块时,必须先获得锁,才能进入代码块进行。执行结束后,在synchronized语句块结束会自动释放锁。这样一来,对count变量进行读写就不可能同时进行。上述代码无论运行多少次,最终结果都是2000。

使用synchronized解决了多线程同步访问共享变量的正确性问题。但是,它的缺点是带来了性能下降。因为synchronized代码块无法并发执行。此外,加锁和解锁需要消耗一定的时间,所以,synchronized会降低程序的执行效率。

3. 有序性问题

顾名思义,有序性指的是程序按照代码的先后顺序执行。执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
int num = 0;
boolean ready = false;

// 线程1 执行此方法
public void actor1(I_Result r) {
    if(ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}

// 线程2 执行此方法
public void actor2(I_Result r) { 
    num = 2;
    ready = true; 
}

场景分析:

  1. 情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1

  2. 情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1

  3. 情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)

  4. 情况4:由于 JIT 指令重排,将num=2; 和 ready = true; 两个赋值语句交换了顺序,线程2 执行 ready = true,切换到线程1,进入if分支,相加为 0,再切回线程2 执行 num = 2

    对于情况4,这个现象需要通过大量测试才能复现,可以借助java并发压测工具 jcstress,这里不展开讲了,这里只关心如何解决

指令重排的类型: 编译器和处理器常常会对指令做重排序。重排序分三种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  3. 内存系统的重排序。由于处理器使用缓存和读/ 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

解决方法:只需要给 ready 加上一个 volatile 即可,volatile 修饰的变量,可以禁用指令重排