Java多线程共享变量:并发编程基础-共享对象

2021年8月13日04:52:37 发表评论 1,763 次浏览

如何实现Java多线程共享变量?作为Java开发人员,我们已经看到了同步块和方法如何确保操作以原子方式执行,但不幸的是,一个常见的误解是同步不仅仅是关于原子性或划分“临界区”,另一个方法是使用Java多线程volatile

synchronization除了实现Java多线程共享变量还有另一个重要方面,主要是内存可见性。我们不仅要防止一个线程在另一个线程使用它时修改对象的状态,还要确保当一个线程修改Java多线程共享对象的状态时,其他线程实际上可以看到所做的更改。

但如果没有同步,这可能就不会发生。您可以通过两种方式确保这一点。Java多线程共享对象也可以安全地发布:

  • 通过使用显式synchronization
  • 通过利用内置于库类中的同步。

1. Java多线程共享变量得可见性

可见性是并发编程的一个关键方面。如果我们在这方面犯了错误,其影响将是巨大的。

在单线程应用程序中,如果您将一个值写入一个变量,然后在没有中间写入的情况下读取该变量,当然您可以期望得到相同的值。

但是当我们使用不同的线程进行读写时,可见性问题就会出现。通常,无法保证读取线程会及时看到另一个线程写入的值,甚至根本无法保证。

为了确保跨线程的内存写入可见性,需要同步。

考虑以下示例,其中aandb是Java多线程共享全局变量或实例字段,但是r1r2是其他线程无法访问的局部变量。

最初,让a = 0b = 0

Java多线程共享变量:并发编程基础-共享对象
Java多线程共享变量

在 中Thread 1,这两个赋值a = 10;r1 = b;是不相关的,因此编译器或运行时系统可以自由地对它们重新排序。中的两个赋值Thread 2也可以自由重新排序。尽管这似乎违反直觉,但 Java 内存模型允许读取查看在明显执行顺序中稍后发生的写入的值。

Java多线程共享变量显示实际分配的可能执行顺序是:

Java多线程共享变量:并发编程基础-共享对象
Java多线程共享变量

在此排序中,r1并分别r2读取变量b和的原始值a,即使它们预计会看到更新的值 20 和 10。 这里的主要问题是,线程 t2 不知道线程 t1 执行的更改.

1.1 Java多线程共享变量:锁定

Java多线程共享变量:并发编程基础-共享对象
Java多线程共享对象

可以利用内在锁定来保证一个线程以可预测的方式看到另一个线程的影响。当线程 t1 执行一个同步块,随后线程 t2 进入由同一个锁保护的同步块时,在获取锁时保证在释放锁之前对 t1 可见的变量的值对 t2 可见。换句话说,当 t1
执行由同一锁保护的同步块时,t1 在同步块中或之前所做的一切对 t2都是可见的。
没有同步,就没有这样的保证。

锁定不仅与互斥有关,还与内存可见性有关。为了确保所有线程都能看到共享
可变变量的最新值,读写线程必须在一个公共锁上同步。

2.2 Java多线程共享变量:Java volatile变量

Java 语言还提供了一种替代的、较弱的同步形式,称为“易失性变量”。

Javavolatile关键字保证跨线程的变量更改的可见性。这可能听起来有点抽象,所以让我详细说明一下。

在线程对非易失性变量进行操作的多线程应用程序中,出于性能原因,每个线程在处理变量时可能会将变量从主内存复制到 CPU 缓存中。

如果您的 PC 包含多个 CPU,则每个线程可能在单独的 CPU 上运行。这意味着,每个线程都可以将变量复制到不同 CPU 的 CPU 缓存中。这如下图所示。

Java多线程共享变量:并发编程基础-共享对象
Java多线程共享变量

对于非易失性变量,无法保证 JVM 何时将数据从主内存读取到 CPU 缓存中,或将数据从 CPU 缓存写入主内存。这可能会导致几个问题,我将在本博客的后面部分解释这些问题。

试想这样一种情况,其中两个或多个线程可以访问一个共享对象,该Java多线程共享对象包含一个声明如下的 numberCounter 变量:

public class SharedNumber {

    public int numberCounter = 0;

}

也想象一下,只有线程 1 增加了numberCounter变量,但线程 1 和线程 2 可能会不时读取numberCounter变量。

如果未声明numberCounter变量,则无法volatile保证numberCounter变量的值何时从 CPU 缓存写回主内存。

Java多线程共享变量:这意味着,CPU 缓存中的numberCounter变量值可能与主内存中的不同。这种情况如下图所示。

Java多线程共享变量:并发编程基础-共享对象
Java多线程共享变量

线程没有看到变量的最新值的问题,因为它尚未被另一个线程写回主内存,正如我之前提到的,被称为“可见性”问题。

让我们看看我们如何解决可变变量的可见性问题。我们走吧 :)

Javavolatile关键字旨在解决变量可见性问题。通过声明numberCounter变量volatile所有写numberCounter变量将被立即写回主内存。此外,所有对numberCounter变量的读取都将直接从主内存中读取。

Java多线程共享对象:numberCounter变量的volatile声明如下所示:

public class SharedNumber {

    public volatile int numberCounter = 0;

}

声明一个变量Java多线程volatile变量从而保证写入该变量的其他线程的可见性

在上面给出的场景中,一个线程 (t1) 修改numberCounter,另一个线程 (t2) 读取numberCounter(但从不修改它),声明numberCounter变量volatile足以保证写入numberCounter变量的t2 的可见性。

但是,如果 T1 和 T2 都在增加numberCounter变量,那么声明numberCounter变量volatile是不够的。稍后再谈。

完全volatile的可见性保证

实际上,Java 的可见性保证volatile超出了volatile变量本身。Java多线程共享变量可见性保证如下:

  • 如果线程 A 写入一个volatile变量,而线程 B 随后读取同一个 volatile 变量,则线程 A 在写入 volatile 变量之前可见的所有变量,在读取 volatile 变量后也对线程 B 可见。
  • 如果线程 A 读取了一个volatile变量,那么所有在读取该volatile变量时线程 A 可见的所有变量也将从主内存中重新读取。

Java多线程共享对象:让我用一个代码示例来说明:

public class MyClass {
    private int years;
    private int months
    private volatile int days;
    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

udpate()方法写入三个变量,其中只有days是volatile。

完全volatile可见性保证意味着,当一个值被写入时days,线程可见的所有变量也被写入主内存。这意味着,当一个值被写入days,的值yearsmonths也被写入主存储器。

当读取的值yearsmonths并且days你可以做这样的:

// Java多线程共享对象
public class MyClass {
    private int years;
    private int months
    private volatile int days;
    public int totalDays() {
        int total = this.days;
        total += months * 30;
        total += years * 365;
        return total;
    }
    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

请注意,该totalDays()方法首先将 的值读daystotal变量。当读取的值days,值monthsyears也被读入到主存储器中。因此,您一定会看到 的最新值daysmonthsyears使用上述读取序列。

Java多线程共享变量:指令重排序挑战

出于性能原因,Java VM 和 CPU 可以对程序中的指令进行重新排序,只要指令的语义保持不变。例如,请查看以下说明:

int a = 1;
int b = 2;
a++;
b++;

这些指令可以按以下顺序重新排序,而不会丢失程序的语义:

'
int a = 1;
a++;
int b = 2;
b++;

然而,当变量之一是Java volatile变量时,指令重新排序会带来挑战。让我们看看MyClass本 Java 易失性教程前面示例中的类Java多线程共享对象:

public class MyClass {
    private int years;
    private int months
    private volatile int days;
    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

一旦update()方法写入一个值days,新写入的值,以yearsmonths也被写入主存储器。但是,如果 Java VM 重新排序指令会怎样,如下所示:

public void update(int years, int months, int days){
    this.days   = days;
    this.months = months;
    this.years  = years;
}

Java多线程共享变量:当变量被修改时,months和的值years仍会写入主内存days,但这次它发生在新值写入months和 之前years。因此,其他线程无法正确地使新值可见。重新排序的指令的语义已经改变。

Java 为这个问题提供了解决方案,我们将在下一节中看到。

Java volatile Happens-Before保证

为了解决指令重新排序的挑战,volatile除了可见性保证之外,Java关键字还提供了“happens-before”保证。发生前保证保证:

  • volatile如果读取/写入最初发生在写入volatile变量之前,则读取和写入其他变量不能重新排序以在写入变量之后发生。
    写入volatile变量之前的读取/写入保证在写入volatile变量之前“发生” 。请注意,例如读取/写入位于写入 a 之后的其他变量的读取/写入仍可能volatile发生在写入volatile. 恰恰相反。允许从后到前,但不允许从前到后。
  • volatile如果读取/写入最初发生在读取变量之后,则读取和写入其他变量不能重新排序以在读取变量之前发生volatile。请注意,在读取变量之前发生的其他变量读取volatile可以重新排序为在读取volatile. 恰恰相反。允许从前到后,但不允许从后到前。

上述发生在保证确保volatile关键字的可见性保证正在被强制执行。

volatile 并不总是足够的

即使volatile关键字保证所有对volatile变量的读取都是直接从主存读取,对变量的所有写入volatile都直接写入主存,但仍然存在声明变量是不够的情况volatile

Java多线程共享变量:在前面解释的只有线程 1 写入共享counter变量的情况下,声明counter变量volatile就足以确保线程 2 始终看到最新的写入值。

事实上,volatile如果写入变量的新值不依赖于其先前值,则多个线程甚至可以写入共享变量,并且仍将正确值存储在主内存中。换句话说,如果一个线程将一个值写入共享volatile变量,则不需要首先读取它的值来找出它的下一个值。

一旦线程需要首先读取volatile变量的值,并基于该值为共享volatile变量生成新值,volatile变量就不再足以保证正确的可见性。读取volatile变量和写入其新值之间的短暂时间间隔会产生竞争条件,在这种情况下,多个线程可能会读取volatile变量的相同值,为变量生成一个新值,然后将值写回主内存 - 覆盖彼此的值。

多个线程递增同一个计数器的情况正是volatile变量不够用的情况。以下部分更详细地解释了这种情况。

关于Java多线程共享变量:想象一下,如果线程 1 将counter值为 0的共享变量读取到其 CPU 缓存中,将其增加到 1 并且不将更改后的值写回主内存。然后,线程 2 可以counter从变量值仍为 0 的主内存中将同一变量读取到其自己的 CPU 缓存中。然后线程 2 也可以将计数器增加到 1,并且也不会将其写回主内存。这种情况如下图所示:

Java多线程共享变量:并发编程基础-共享对象
Java多线程共享变量

线程 1 和线程 2 现在几乎不同步。共享counter变量的实际值应该是 2,但每个线程在其 CPU 缓存中的变量值为 1,而在主内存中该值仍为 0。这是一团糟!即使线程最终将共享counter变量的值写回主内存,该值也会是错误的。

什么时候volatile足够?

正如我之前提到的,如果两个线程同时读取和写入共享变量,那么使用volatile关键字是不够的。在这种情况下,您需要使用synchronized来保证变量的读写是原子的。读取或写入 volatile 变量不会阻止线程读取或写入。为此,您必须synchronized在关键部分周围使用关键字。

作为synchronized块的替代方案,您还可以使用包中的许多原子数据类型之一java.util.concurrent。例如,AtomicLongAtomicReference或其他之一。

如果只有一个线程读取和写入 volatile 变量的值而其他线程只读取该变量,则保证读取线程看到写入 volatile 变量的最新值。如果不使变量可变,则无法保证这一点。

volatile关键字保证适用于 32 位和 64 个变量。

Java多线程共享变量:volatile 的性能考虑

易失性(volatile)变量的读取和写入导致变量被读取或写入主存储器。读取和写入主内存比访问 CPU 缓存更昂贵。访问易失性变量还可以防止指令重新排序,这是一种正常的性能增强技术。因此,您应该只在确实需要强制执行变量可见性时才使用 volatile 变量。

synchronization 和 volatile 关键字的区别

Volatile 关键字不能替代synchronized 关键字,但在某些情况下可以用作替代。有以下区别如下:

Java多线程共享变量:并发编程基础-共享对象
Java多线程共享变量

Java多线程共享变量:作为 volatile 变量的总结,您只有在满足以下所有条件时才能使用Java多线程volatile变量:

  • 对变量的写入不依赖于它的当前值,或者你可以确保只有一个线程更新这个值;
  • 该变量不与其他状态变量组成不变量;
  • Java多线程共享对象:在访问变量时,不需要因为任何其他原因而使用锁。
木子山

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: