理解并正确使用synchronized和volatile

简介: 线程安全需要同时满足两个条件: 线程可见性 操作的原子性 volatile能保证其修饰的变量的线程可见性但无法保证操作原子性,只能用于"多个变量之间或者某个变量的当前值与修改后值之间没有约束"的场景。

线程安全需要同时满足三个条件:

  • 原子性
    某个操作是不可中断的,且要么全部做完要么没有执行。
  • 可见性
    通过volatile关键字修饰变量实现。读取volatile变量时,先失效本地缓存再读取主存中的最新值;更新volatile变量会立即将最新值刷回主存。
  • 有序性
    JMM的happens-before原则。

volatile能保证其修饰的变量的线程可见性但无法保证操作原子性,只能用于"多个变量之间或者某个变量的当前值与修改后值之间没有约束"的场景。在实现计数器(++count)和由多个变量组成的不变表达式方面,volatile无法胜任。

volatile的底层实现机制是什么?被volatile修饰的变量在进行写操作时,处理器会插入一条lock前缀的汇编代码,做了层"内存屏障",其作用为:

    1. 重排序时不能把后面的指令重排序到内存屏障之前的位置
    1. 将当前处理器缓存行的数据会写回到系统内存。
    1. 这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效。

通过处理器之间的缓存一致性协议,当(处理器本)地缓存过期后会失效本地缓存,当更新该缓存时处理器重新从主存load数据到本地缓存并执行计算逻辑。

什么场景下别用volatile?

非线程安全的计数器类

自增操作并不是原子的,比如"++count"操作就是三个原子操作的集合:

  1. 读取count旧值
  2. 线程上下文汇总设置count新值: count+1
  3. 将count新值刷回主存并失效其他线程上下文的count值

假设thread1和thread2均执行"++count"计数操作,thread1和thread2均执行完2但未执行3,此时thread1和thread2先后将count新值刷回主存,这就产生了线程不安全的现象。

只有在状态真正独立于程序内其他内容时才能使用volatile。

// 引用
volatile操作不会像锁一样造成阻塞,因此,在能够安全使用volatile的情况下,volatile 可以提供一些优于锁的可伸缩特性。如果读操作的次数要远远超过写操作,与锁相比,volatile 变量通常能够减少同步的性能开销。

非线程安全的数值范围类

// 代码来源于Brian Goetz的《正确使用 Volatile 变量》一文,本文稍作修改
@NotThreadSafe 
public class NumberRange {
    private volatile int lower, upper;
 
    public int getLower() { return lower; }
    public int getUpper() { return upper; }
 
    public void setLower(int value) { 
        if (value > upper) 
            throw new IllegalArgumentException(...);
        lower = value;
    }
 
    public void setUpper(int value) { 
        if (value < lower) 
            throw new IllegalArgumentException(...);
        upper = value;
    }
}

假设NumberRange初始化后lower/upper分别为0和4,即数值范围为[0,4]。此时thread1和thread2分别执行setLower(3)和setUpper(2),最终lower/upper分别被thread1和thread2设置为3和2,数值范围被更新为[3,2],某种意义上看是一个无效的数值范围。

什么场景下可以使用volatile?

1、对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
2、该变量没有包含在具有其他变量的不变式中。

Case1: 状态标志

这种类型的状态标记的一个公共特性是:通常只有一种状态转换。

// 代码来源于Brian Goetz的《正确使用 Volatile 变量》一文,本文稍作修改
volatile boolean shutdownRequested;
public void shutdown() { shutdownRequested = true; }
public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}

Case2: 一次性安全发布 - One-time Safe Publication

// 代码来源于Brian Goetz的《正确使用 Volatile 变量》一文,本文稍作修改
public class Floor {
    public Floor() {
        // initialization...
    }
}
public class Loader {
    public volatile Floor floor;
    public void init() {
        // this is the only write to floor
        floor = new Floor();
        // initialize floor object here ...
    }
    
    public Floor getFloor() {
        return this.floor;
    }
}
 
public class SomeOtherClass {
private Loader loader;
    public void doWork() {
        while (true) { 
            // use "floor" only if it is ready
            if (loader != null)  // 步骤1
                doSomething(loader.getFloor()); // 步骤2
        }
    }
}

这是一个典型的读写冲突例子,引文中提到"如果Floor引用不是 volatile 类型,doWork() 中的代码在解除对Floor的引用时,将会得到一个不完全构造的Floor"。我对这句理解的是:thread2执行完步骤2后释放对Floor的引用时,thread1可能正在调用init方法初始化floor,此时thread2拿到的是还未被thread1完全初始化的Floor对象。如果doWork内部主动解除对floor对象的引用,则可能拿到未初始化完全的floor对象的引用。

即使floor对象完成初始化,对floor成员域的修改仍然是线程不可见的。

volatile引用可以保证任意线程都可以看到这个对象引用的最新值,但不保证能看到被引用对象的成员域的最新值。

因为volatile修饰的是floor对象的引用,如果thread1执行到步骤1时,thread3修改了floor成员域,其修改对thread1并不可见。思考:如果floor成员域均被volatile锁修饰,其成员域的修改是否对thread3可见

Case3: 多个独立观察结果的发布

// 代码来源于Brian Goetz的《正确使用 Volatile 变量》一文,本文稍作修改
public class UserManager {
    public volatile String lastUser;
    public boolean authenticate(String user, String password) {
        boolean valid = passwordIsValid(user, password);
        if (valid) {
            User u = new User();
            activeUsers.add(u);
            lastUser = user;
        }
        return valid;
    }
}

这个模式要求被发布的值(lastUser)是有效不可变的 —— 即值的状态在发布后不会更改。与Case1中更新floor对象成员域不同,对String类的操作是在新的String实例上进行的,String对象本身的状态并未改变。String类的这种实现方式天然地提供了线程隔离性。

volatile并非用来解决高并发场景下数据竞争冲突的方案,它只是实现线程可见性的一种方式!如果多个线程同时更新volatile变量,需要采用同步机制解决数据竞争,如CAS或者锁等。

Case4: "volatile bean" 模式

该模式中,Java Bean成员变量均被volatile修饰,且引用类型的成员变量也必须是有效不可变(Collection的子类如List, Set, Queue等有数组值的成员变量,volatile修饰的是数组引用并非数组元素!)。

引用文章

目录
相关文章
|
Java
java 读取文件 获取byte[]字节 并执行Gzip的压缩和解压
java 读取文件 获取byte[]字节 并执行Gzip的压缩和解压
354 0
|
9月前
|
存储 消息中间件 缓存
支持百万人超大群聊的Web端IM架构设计与实践
本文将回顾实现一个支持百万人超大群聊的Web端IM架构时遇到的技术挑战和解决思路,内容包括:通信方案选型、消息存储、消息有序性、消息可靠性、未读数统计。希望能带给你启发。
318 0
支持百万人超大群聊的Web端IM架构设计与实践
|
Web App开发 数据可视化
如何轮播 DataV 大屏
如何轮播 DataV 大屏 当你使用 DataV 制作了足够多的大屏时,一定会冒出一个需求:轮流播放大屏页面,不要怕,一分钟就可以搞定 安装 Chrome 插件 TabCarousel 首先安装神器插件 TabCarousel 使用 安装完成之后,地址栏右侧会出现这么个小图标 。
19543 154
如何轮播 DataV 大屏
|
IDE Java 开发工具
【Eclipse安装及使用(面向小白)】
【Eclipse安装及使用(面向小白)】
|
SQL 数据处理
SQL 能力问题之合并两个存在交叉的日期区间,如何解决
SQL 能力问题之合并两个存在交叉的日期区间,如何解决
|
存储 安全 Java
提升编程效率的利器: 解析Google Guava库之集合篇Table二维映射(四)
提升编程效率的利器: 解析Google Guava库之集合篇Table二维映射(四)
|
存储 数据挖掘 Python
Python技术分享:实现选择文件或目录路径的方法
Python技术分享:实现选择文件或目录路径的方法
1108 2
一起来学kafka之整合SpringBoot深入使用(一)
前言 目前正在出一个Kafka专题系列教程, 篇幅会较多, 喜欢的话,给个关注❤️ ~ 本节给大家讲一下Kafka整合SpringBoot中如何进行消息应答以及@SendTo 和 @KafkaListener的讲解~ 好了, 废话不多说直接开整吧~ 消息应答 有时候,消费者消费消息的时候,我们需要知道它有没有消费完,需要它给我们一个回应,该怎么做呢? 我们可以通过提供的ReplyingKafkaTemplate, 下面通过一个例子来体验一下,新建一个ReceiveCustomerController
|
存储 缓存 Rust
Rust 笔记:Rust 语言中映射(HashMap)与集合(HashSet)及其用法
本文介绍 Rust 中哈希结构相关概念及其使用。在 Rust 中,提供了两种哈希表,一个是 HashMap,另外一个是 HashSet,本文都将逐一介绍,并介绍 哈希函数 的用法。
569 0