Java并发编程之线程安全

74 阅读6分钟

上一篇文章,总结了Java并发编程的基础知识,这篇文章从线程通信开始说起,浅浅了解下为什么会发生线程冲突以及解决线程冲突的方式。

一、Java是如何进行线程通信及线程同步的

Java采用共享内存的并发模型,线程之间共享程序的公共状态,通过读-写内存的公共状态进行隐式通信。 在共享内存并发模型里,同步是显示进行。程序员必须显示指定某个方法或某段代码需要在线程之间互斥执行。比如加锁。

(1)Java是如何进行隐式通信的

内存模型.png 这里引用《Java并发编程的艺术》中的配图。原汤化原食。 图中共享变量指的是所有的实例域、静态域、和数组元素。 这些元素都存储在堆内存中,堆内存在线程之间共享。 而本地内存则是一个抽象的概念,涵盖了缓存、写缓冲区、寄存器等。

如果线程A和线程B之间要通信,必须经历两个步骤。 1.线程A把本地内存更新过的共享变量刷新到主内存去。 2.线程B到主内存去读线程A之前已更新过的共享变量。 整体来看,这个过程必须经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来保证内存可见性。

JMM也就是Java内存模型,他确保在不同的编译器和不同的处理器平台上,通过禁止特定类型的编译器重排序,和处理器重排序,为程序员提供一致的内存一致性保证。

(2)什么是内存可见性

前文已经提过,本地内存这一抽象概念中有写缓冲区这个东西。 写缓冲区可以临时保存向内存写入的数据。保证指令流水线持续运行,避免由于处理器停顿下来等待向内存写入数据而产生的延迟。 同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少内存总线的占用。 但每个处理器上的写缓冲区仅对它所在的处理器可见。这也就导致了一个问题: 处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读-写操作顺序一致。

public class Test {
    public static int a = 0;
    public static int b = 0;
    public static void main(String[] args) {
        new Thread(()-> {
            a = 1;
            int x = b;
            System.out.println(x);
        }).start();

        new Thread(() ->{
            b = 2;
            int y = a;
            System.out.println(y);
        }).start();
    }
}

这里附上一段代码,笔者自己运行的结果是0,1,猜测可能是因为并发量太小,单核处理器就运行完毕的的原因。但如果并发量大,多核处理,x=0,y=0;x=2,y=0...结果也是都有可能出现的。 具体原因如下图分析: image.png 依旧是原汤化原食。可以看到处理器A和处理器B同时把共享变量写入到自己的写缓冲区中(A1,B1),然后从内存中读取另一个共享变量(A2,B2)。最后才把写缓冲中的脏数据(A3,B3)写到内存中。

说人话就是两个线程一起启动,A线程读到a=0,并将其改为a=1,但他没有立刻将脏数据刷新到内存,而是去读b,这个时候B线程本地内存中b=0已经变为了b=2但是他也没有立即刷新到内存,导致a读到的依旧是b=0。而B从内存中读变量a的时候,读到的也是a=0。

原本我们程序的执行顺序应该是a=1,b=2->x=b,y=a。现在则变成了x=b,y=a->a=1,b=2。这就是处理器重排序。 连我一个实习时长俩月半的菜狗都知道这种情况不避免会发生大问题的。

避免的办法之一就是让一个操作的执行结果对另一个操作可见。也就是保证内存可见性。说人话就是如果A线程将a=0改为了a=1,那么B线程也会将本地内存中的a=0变为a=1。 在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么两个操作之间必须存在happens-before关系。这两个操作既可以是在一个线程内,也可以在不同线程之间。

happens-before的规则是什么

1.程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
2.监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
3.volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
4.传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

终于到主角出场了。监视器锁规则可不就是synchronized嘛,volatile想必大家也并不陌生。至于1和4我只能说他俩存在的意义可能就是为了证明A happens-before B不一定就代表着A一定比B先执行。

为什么加锁可以保证内存可见性

那这还用说吗?我进入方法操作变量的时候把门锁上,大家排好队一个一个来,怎么会冲突呢? 可是前面我们就说过这里线程操作的其实是处理器写缓冲区中的变量,也就是大家都有自己的一份变量。 你锁上门修改完了,轮到我的时候,我拿着的可是没修改前的副本啊。那你不白干吗?

这里我就要介绍一下锁的内存语义了。

当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。

当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。

这样一来就可以保证对于加锁的共享变量,每一个线程拿到的都是最新的值。

用volatile修饰变量和加锁也有差不多的含义

当写一个volatile变量时,JMM会把本地内存中的共享变量刷新到主内存。

当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。

但有一点我们需要知道,volatile可以保证变量的可见性,但是只能变量单个读写操作的原子性。

至于原因嘛,笔者懒得CV了,大家就原汤化原食吧

image.png 说白了当你i++复合操作的时候 get方法和set方法可能发生指令重排。那哥仨顺序一错会发生啥想都不敢想啊。

下一章就对synchronized的原理展开讲讲吧。