并发编程(二) — 内存可见性问题

文章探讨了在多线程环境下,共享变量的内存可见性问题,以及如何通过synchronized和volatile关键字解决这一问题。synchronized提供内置锁确保同步,而volatile保证变量的即时可见性但不保证原子性。此外,文章还介绍了CAS算法,一种无锁同步机制,用于原子性地更新变量,避免线程阻塞。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

前言

内存可见性问题

synchronized

volatile

CAS算法

CAS算法原理

CAS算法应用场景

CAS算法代码实现

参考目录


前言

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

在图2-3 中, 线程A 和线程B 可以同时操作主内存中的共享变量,当同时操作同一个共享变量时,可能导致线程安全问题。

注:这里说的操作是指写操作,多个读操作并不会产生线程安全问题,因为并没有修改共享变量的数据。只有当至少一个线程修改共享资源时才会存在线程安全问题。

例:

        线程A、B在同一时间段内访问贡献变量count资源(count为一个计数器,每次做加一操作)

        假如当前count=0 ,在t1时刻线程A 读取count 值到本地变量countA 。然后在t2 时刻递增countA 的值为1,同时线程B 读取count 的值0 到本地变量countB ,此时countB的值为0 (因为countA 的值还没有被写入主内存)。在t3 时刻线程A 才把countA 的值1写入主内存, 至此线程A 一次计数完毕,同时线程B 递增CountB 的值为1 。在t4 时刻线程B 把countB 的值1写入内存,至此线程B 一次计数完毕。最后结果为1,与预期的2不符。

t1

t2

t3

t4

线程A

从内存读取count值到本线程

递增本线程count的值

写回主内存

线程B

从内存读取count值到本线程

递增本线程count的值

写回主内存

注:当线程使用变量时,会把主内存里面的变量复制到自己的工作空间或者叫作工作内存线程读写变量时操作的是自己工作内存中的变量。

内存可见性问题

        上面提到了在多线程下,线程使用变量时,会把主内存里面的变量复制到自己的工作空间或者叫作工作内存线程读写变量时操作的是自己工作内存中的变量。其内存模型如下:

Java 内存模型是一个抽象的概念,那么在实际实现中线程的工作内存是什么呢?

        图中所示是一个双核CPU 系统架构,每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辅运算。每个核都有自己的一级缓存,在有些架构里面还有一个所有CPU 都共享的二级缓存。那么Java 内存模型里面的工作内存,就对应这里的Ll 或者L2 缓存或者CPU 的寄存器。

        当一个线程操作共享变量时, 它首先从主内存复制共享变量到自己的工作内存, 然后对工作内存里的变量进行处理, 处理完后将变量值更新到主内存。

        那么假如线程A 和线程B 同时处理一个共享变量, 会出现什么情况?

        我们使用上图所示CPU 架构, 假设线程A 和线程B 使用不同CPU 执行,并且当前两级Cache 都为空,那么这时候由于Cache 的存在,将会导致内存不可见问题, 具体看下面的分析。

  • 线程A 首先获取共享变量X 的值,由于两级Cache 都没有命中,所以加载主内存中X 的值,假如为0。然后把X=0 的值缓存到两级缓存, 线程A 修改X 的值为1,然后将其写入两级Cache , 并且刷新到主内存。线程A 操作完毕后,线程A 所在的CPU 的两级Cache 内和主内存里面的X 的值都是1。
  • 线程B 获取X 的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了,所以返回X= 1 ; 到这里一切都是正常的, 因为这时候主内存中也是X= 1 。然后线程B 修改X 的值为2 , 并将其存放到线程B所在的一级Cache 和共享二级Cache 中,最后更新主内存中X 的值为2 ; 到这里一切都是好的。
  • 线程A 这次又需要修改X 的值, 获取时一级缓存命中, 并且X= 1 ,到这里问题就出现了,明明线程B 已经把X 的值修改为了2 ,为何线程A 获取的还是1呢? 这就是共享变量的内存不可见问题, 也就是线程B 写入的值对线程A 不可见。

所以,共享变量内存可见性问题主要是由于线程的工作内存导致的!

如何解决共享变量的内存不可见性呢?

synchronized

        没有什么问题是一把锁解决不了的,不行就再加一把🤣🤣

  synchronized 块是Java 提供的一种原子性内置锁, Java 中的每个对象都可以把它当作一个同步锁来使用, 这些Java 内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。线程的执行代码在进入synchronized 代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用了该内置锁资源的wait 系列方法时释放该内置锁。内置锁是排它锁,也就是当一个线程获取这个锁后, 其他线程必须等待该线程释放锁后才能获取该锁。

        另外,由于Java 中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而synchronized 的使用就会导致上下文切换


        讲完了synchronized 关键字,介绍一下synchronized 的内存语义,即如何解决上面的内存可见性问题的。

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

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

        虽然这玩意确实好用,but synchronized 关键字会引起线程上下文切换并带来线程调度开销!!!

volatile

        上面提到synchronized 锁太重了,会引起线程上下文切换并带来线程调度开销。所以又提供了另外一种解决内存可见性的方法。

        Java 提供了一种弱形式的同步,也就是使用volatile 关键字。该关键字可以确保对一个变量的更新对其他线程马上可见。当一个变量被声明为volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。当其他线程读取该共享变量时 ,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。volatile 的内存语义和synchronized 有相似之处,具体来说就是,当线程写入了volatile 变量值时就等价于线程退出synchronized同步块(把写入工作内存的变量值同步到主内存),读取volatile 变量值时就相当于进入同步块先清空本地内存变量值,再从主内存获取最新值) 。

下面看一个使用volatile 关键字解决内存可见性问题的例子。如下代码中的共享变量value 是线程不安全的,因为这里没有使用适当的同步措施。

public class ThreadNotSafeinteger (
    private int value ;
    public int get() {
    	return value;
	}

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

首先来看使用synchronized 关键宇进行同步的方式。

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

然后是使用volatile 进行同步:

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

在这里使用synchronized 和使用volatile 是等价的,都解决了共享变量value 的内存可见性问题,但是前者是独占锁同时只能有一个线程调用get()方法,其他调用线程会被阻塞,同时会存在线程上下文切换和线程重新调度的开销,这也是使用锁方式不好的地方。而后者是非阻塞算法, 不会造成线程上下文切换的开销。但并非在所有情况下使用它们都是等价的, volatile 虽然提供了可见性保证,但并不保证操作的原子性。

那么一般在什么时候才使用volatile 关键字呢?

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

CAS算法

        在Java 中, 锁在并发处理中占据了一席之地,但是使用锁有一个不好的地方,就是当一个线程没有获取到锁时会被阻塞挂起, 这会导致线程上下文的切换和重新调度开销。Java 提供了非阻塞的volatile 关键字来解决共享变量的可见性问题, 这在一定程度上弥补了锁带来的开销问题,但是volatile 只能保证共享变量的可见性不能解决读—改一写等的原子性问题

CAS算法原理

  CAS(Compare And Swap)算法是一种无锁的同步机制,是非阻塞原子性操作,它的原理是先比较内存中的值是否与期望值相等,如果相等,则将新值写入内存;否则不做任何操作。CAS算法的核心是原子性操作,即在执行比较和写入操作时,不会被其他线程干扰。(通过硬件保证了比较更新操作的原子性)

CAS算法的基本流程如下:

  1. 读取内存中的值;
  2. 比较内存中的值与期望值是否相等;
  3. 如果相等,则将新值写入内存;
  4. 如果不相等,则不做任何操作。

CAS算法的优点是无锁,可以避免线程阻塞,提高程序的并发性能。但是CAS算法也有一些缺点,比如ABA问题、循环时间长等。

ABA 问题具体如下: 假如线程I 使用CAS 修改初始值为A 的变量X , 那么线程I 会首先去获取当前变量X 的值(为A ), 然后使用CAS 操作尝试修改X 的值为B , 如果使用CAS 操作成功了, 那么程序运行一定是正确的吗?其实未必,这是因为有可能在线程I 获取变量X 的值A 后,在执行CAS 前,线程II 使用CAS 修改了变量X 的值为B ,然后又使用CAS 修改了变量X 的值为A 。所以虽然线程I 执行CAS时X 的值是A , 但是这个A 己经不是线程I 获取时的A 了。这就是ABA 问题。

ABA 问题的产生是因为变量的状态值产生了环形转换,就是变量的值可以从A 到B,然后再从B 到A。如果变量的值只能朝着一个方向转换,比如A 到B , B 到C , 不构成环形,就不会存在问题。JDK 中的AtomicStampedReference 类给每个变量的状态值都配备了一个时间戳, 从而避免了ABA 问题的产生。

CAS算法应用场景

CAS算法适用于多线程环境下的共享变量的更新操作。比如计数器、标志位等。CAS算法可以保证在多线程环境下,共享变量的更新操作是原子性的,避免了线程安全问题。

CAS算法代码实现

在Java中,CAS算法的实现是通过Unsafe类来实现的。Unsafe类提供了一些底层的操作,比如内存操作、线程操作等。下面是一个使用CAS算法实现计数器的示例代码:

import sun.misc.Unsafe;

public class Counter {
    private volatile int count;
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long offset;

    static {
        try {
            offset = unsafe.objectFieldOffset(Counter.class.getDeclaredField("count"));
        } catch (Exception ex) {
            throw new Error(ex);
        }
    }

    public void increment() {
        int current;
        do {
            current = unsafe.getIntVolatile(this, offset);
        } while (!unsafe.compareAndSwapInt(this, offset, current, current + 1));
    }

    public int getCount() {
        return count;
    }
}

在上面的代码中,我们使用了volatile关键字来保证count变量的可见性。在increment方法中,我们使用了do-while循环来不断尝试更新count变量的值。如果更新成功,则退出循环;否则继续尝试更新。在更新操作中,我们使用了compareAndSwapInt方法来实现CAS算法。

参考目录

《Java并发编程之美》

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值