Java 并发编程-线程安全


本文为《Java 并发编程之美第2章》的笔记总结

一、 什么是线程安全

首先要先解释一下什么是共享资源,即被多个线程所持有的资源,或者说多个线程都可以去访问的资源。

线程安全问题是指当多个线程同时读写一个共享资源并且没有任何同步措施时,导致出现脏数据或其他不可预见的结果的问题。

线程安全主要体现在三个方面:原子性可见性有序性。下面会对每个方面进行举例介绍并给出对应的解决方案。


二、 可见性问题

可见性:即一个线程对主内存的修改可以及时地被其他线程看到。

2.1 产生原因

单核CPU不会产生内存可见性问题。首先明确一点,可见性问题只会产生在多核CPU环境下。单核CPU由于同一时刻只会有一个线程执行,而每个线程执行的时候操作的都是同一个CPU的缓存,所以,单核CPU不存在可见性问题。

工作内存和主内存的概念:Java 内存模型规定,将内存分为主内存和和工作内存。所有的变量放在主内存中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间或者叫工作内存,线程读写变量时操作的是自己工作内存中的变量。

工作内存的实际实现就是 CPU 缓存,对于多核CPU,每个核心有自己单独的缓存(L1 Cache),有些架构里还会有多个核心共享的缓存(L2 Cache),如下图是个双核CPU架构。
在这里插入图片描述
下图展示了一个可见性问题产生的例子。

在这里插入图片描述

  1. 主内存中有一个共享变量X=0。线程1 要执行X = X+1 操作,先去缓存中查找 X,未命中,从主内存中加载值,并缓存到二级缓存中。将计算结果X = 1存入L1 Cache。
  2. 线程1 将结果刷新到主内存。
  3. 线程2 也要对变量 X + 1,先去缓存中查找 X,未命中,从主内存中加载值,并缓存到二级缓存中。将计算结果X = 2 存入L1 Cache。
  4. 线程2 将结果刷新到主内存。
  5. 线程1 又要对 X + 1,命中缓存中的值 X = 1,执行操作后 X = 2,但我们希望的结果是 X = 3,这就产生了可见性问题。

如下代码中的共享变量 value 是线程不安全的

public class ThreadNoSafeInteger {
    
    private int value;
    
    public int get() {
        return value;
    }
    
    public void set(int value) {
        this.value = value;
    }
}

2.2 解决办法

2.2.1 synchronized

synchronized 块是 Java 提供的一种原子性内置锁,Java 的每个对象可以把它当作一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内部锁,也称作监视器锁

synchronized 的一个内存语义可以解决共享变量的可见性问题:

  • 进入 synchronized 块的内存语义是把 synchronized 块内使用到的变量从线程的工作内存中清除,这样 synchronized 块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主内存中获取。
  • 退出 synchronized 块的内存语义是把在 synchronized 块内对共享变量的修改刷新到主内存。

修改后的代码如下

public class ThreadNoSafeInteger {

    private int value;

    public synchronized int get() {
        return value;
    }

    public synchronized void set(int value) {
        this.value = value;
    }
}

加锁和释放锁的语义来说:

  • 获取锁后会清空锁块内本地内存中将会被用到的共享变量,在使用这些共享变量时从主内存进行加载,
  • 释放锁时将本地内存中修改的共享变量刷新到主内存。

除了可以解决共享变量内存可见性问题外,synchronized 经常被用来实现原子性操作

synchronized 关键字会引起线程上下文切换并带来线程调度开销 。

2.2.2 volatile

除了使用锁的方式,Java 提供了一种弱形式的同步 volatile 关键字。这个关键字可以确保对一个变量的修改对其他线程马上可见。

当一个变量被声明为 volatile 时:

  • 线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。
  • 当其他线程读取该共享变量时,会从主内存中重新获取最新值,而不是使用当前线程的工作内存中的值

volatile 虽然提供了可见性保证,但并不保证操作的原子性

一般在下面情况下会使用 volatile:

  • 写入变量值不依赖变量的当前值。如果依赖当前值,将是获取—计算—写入三步操作,这三步操作不是原子性的,而 volatile 不保证原子性。
  • 读写变量值时没有加锁。因为加锁本身已经保证了内存可见性,这时候不需要把变量声明为 volatile 。

修改后的代码如下

public class ThreadNoSafeInteger {

    private volatile int value;

    public  int get() {
        return value;
    }

    public  void set(int value) {
        this.value = value;
    }
}

三、 原子性问题

原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作

原子性操作:是指执行一系列操作时,这些操作要么全部执行,要么全部不执行,不存在只执行其中一部分的情况。比如一个操作过程为读—改—写,如果不能保证这个过程是原子性的,那么就会出现线程安全问题。

3.1 产生原因

如下代码,++value操作不是线程安全的。

public class ThreadNoSafeCount {
    private int value;
    
    public int getValue() {
        return value;
    }
    
    public void inc() {
        ++value;
    }
}

通过 javap -c 查看汇编代码,红框中对应的时++value的汇编代码。
在这里插入图片描述
++value 操作由2、5、6、7四步组成。第 2 步是获取当前 value 值并放入栈顶,第 5 步把常量 1 放入栈顶, 第 6 步把当前栈顶中两个值相加并把结果放入栈顶,第 7 步则把栈顶的结果赋值给 value 变量。因此,Java 中简单的一句 ++value 被转换为汇编后就不具有原子性了。

3.2 解决办法

3.2.1 synchronized

最简单的办法就是使用 synchronized 关键字进行同步。修改后代码如下

public class ThreadNoSafeCount {
    private int value;
    
    // get 方法用 synchronized 是为了保证内存可见性
    public synchronized int getValue() {
        return value;
    }

    public synchronized void inc() {
        ++value;
    }
}

缺点也非常明显,加了 synchronized 关键字后,同一时间就只能有一个线程可以调用,大大降低了并发性

3.2.2 CAS

CAS ,即 Compare and Swap,是 JDK 提供的非阻塞原子性操作,它通过硬件保证了比较—更新操作的原子性。

JDK 的 rt 包下的 Unsafe 类提供了一系列的 compareAndSwap* 方法。

以 compareAndSwapLong 方法为例

  • boolean compareAndSwapLong (Object obj,long valueOffset, long expect, long update)
  • CAS 有四个操作数,分别为:对象内存位置、对象中的变量的偏移量、变量的预期值和新的值。
  • 其操作含义是,如果对象 obj 中内存偏移量为 valueOffset 的变量值为 expect,则使用新的 update 替换旧的值 expect。
  • 这是处理器提供的一个原子性指令。

CAS 操作会产生一个经典问题 ABA 问题

Unsafe 类
JDK 的 rt 包下的 Unsafe 类提供了硬件级别的原子性方法,Unsafe 类中的方法都是 native 方法。

应用程序不能直接实例化 Unsafe 类,这是因为 Unsafe 类在实例化时进行了类加载器检查,rt.jar 包里面的类是使用 Bootstrap 类加载器加载的,而应用程序的启动 main 函数是使用 AppClassLoader 加载的。限制了开发人员直接使用 Unsafe 类对内存进行操作,因为这是不安全的。

可以通过反射来实例化 Unsafe 类进行CAS操作。代码如下

public class TestUnsafe {

    static final Unsafe unsafe;

    private volatile long state = 0;

    // state 在 TestUnsafe 中的偏移量
    static final long stateOffset;

    static {

        try {
            // 通过反射获取 Unsafe 的成员变量 theUnsafe
            Field field = Unsafe.class.getDeclaredField("theUnsafe");

            // 设置为可存取
            field.setAccessible(true);

            // 获取该变量值
            unsafe = (Unsafe) field.get(null);

            // 获取 state 在 TestUnsafe 中的偏移量
            stateOffset = unsafe.objectFieldOffset(TestUnsafe.class.getDeclaredField("state"));


        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }

    public static void main(String[] args) {
        TestUnsafe test = new TestUnsafe();
        Boolean success = unsafe.compareAndSwapInt(test, stateOffset, 0, 1);
        System.out.println(success);
    }

}

四、有序性问题

有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序

4.1 产生原因

Java 内存模型允许编译器和处理器对指令重排序以提高云心性能,并且只会对不存在数据依赖性的指令重排序。在单线程下重排序可以保证最终执行结果与程序顺序执行的结果一致,但是在多线程环境下就会存在问题。

例如

int a = 1;  //(1)
int a = 2;  //(2)
int c = a+b;//(3)

变量 c 的值依赖a 和 b 的值,所以重排序后能保证(3)的操作在(2)和(1)之后,但是(1)和(2)的执行顺序就不一定了。

4.2 解决办法

4.2.1 volatile

使用 volatile 修饰变量可以避免重排序和内存可见性问题。

volatile 的底层实现原理通过内存屏障:

  • 对 volatile 变量的写指令后加入写屏障
  • 对 volatile 变量的读指令前加入读屏障

写 volatile 变量时,可以确保 volatile 写之前的操作不会被编译器重新排序到volatile 写之后。读 volatile 变量时,可以确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。


五、总结

在这里插入图片描述

原创文章,作者:奋斗,如若转载,请注明出处:https://blog.ytso.com/272429.html

(0)
上一篇 2022年7月9日
下一篇 2022年7月9日

相关推荐

发表回复

登录后才能评论