原子性 / 可见性 / 同步的区别是什么?

353

原子变量 / 易失性变量 / 同步化如何在内部工作?

以下代码块有什么区别?

代码块1

private int counter;

public int getNextUniqueIndex() {
    return counter++; 
}

代码2

private AtomicInteger counter;

public int getNextUniqueIndex() {
    return counter.getAndIncrement();
}

代码 3

private volatile int counter;

public int getNextUniqueIndex() {
    return counter++; 
}

volatile关键字的作用是什么?它是以怎样的方式工作的呢?


volatile int i = 0;
void incIBy5() {
    i += 5;
}

等同于

Integer i = 5;
void incIBy5() {
    int temp;
    synchronized(i) { temp = i }
    synchronized(i) { i = temp + 5 }
}

我认为两个线程不能同时进入一个同步代码块... 我是对的吗?如果是这样,那么atomic.incrementAndGet()如何在没有synchronized的情况下工作?它是线程安全的吗?

另外,对于volatile变量/原子变量的内部读写操作有什么区别?我在某篇文章中读到过线程具有变量的本地副本 - 那是什么意思?


5
@JBNizet 你是正确的!!!我有那本书,但它没有简要的原子概念,我对其中一些概念也不理解。当然这是我的错误而不是作者的责任。 - hardik
5
你不必真的关心它是如何实现的(因为它会因操作系统而异)。你需要了解的是契约:该值被原子地递增,且所有其他线程都保证能看到新值。 - JB Nizet
7个回答

455

您特别关注它们的 内部工作方式,因此这里是:

无同步

private int counter;

public int getNextUniqueIndex() {
  return counter++; 
}

它基本上从内存中读取值,将其增加并放回到内存中。这在单个线程中可以工作,但现今的多核、多 CPU、多级缓存的时代中,它无法正常工作。首先,它会引入竞态条件(多个线程可以同时读取该值),而且可见性问题也会出现。该值可能仅被存储在“本地” CPU 内存(某个缓存)中,而不对其他 CPU/核心(以及因此 - 线程)可见。这就是为什么许多人提到线程中的“本地副本”变量,这非常不安全。考虑以下流行但有缺陷的线程停止代码:

private boolean stopped;

public void run() {
    while(!stopped) {
        //do some work
    }
}

public void pleaseStop() {
    stopped = true;
}

volatile 添加到 stopped 变量中,它就可以正常工作了 - 如果任何其他线程通过 pleaseStop() 方法修改 stopped 变量,您保证能够立即在工作线程的 while(!stopped) 循环中看到该变化。顺便说一句,这也不是中断线程的好方法,请参见:如何停止永远运行而没有任何用处的线程停止特定的Java线程

AtomicInteger

private AtomicInteger counter = new AtomicInteger();

public int getNextUniqueIndex() {
  return counter.getAndIncrement();
}

AtomicInteger 类使用 CAS(比较并交换)低级 CPU 操作(无需同步!),这些操作允许您仅在当前值等于其他值(并已成功返回)时修改特定变量。因此,当您执行 getAndIncrement() 时,它实际上在循环中运行(简化的真实实现):

int current;
do {
  current = get();
} while(!compareAndSet(current, current + 1));

基本上:读取;尝试存储递增值;如果不成功(该值不再等于current),则重新读取并尝试。 compareAndSet()是在本地代码(汇编)中实现的。

没有同步的volatile

private volatile int counter;

public int getNextUniqueIndex() {
  return counter++; 
}

这段代码不正确。它解决了可见性问题(volatile确保其他线程可以看到对counter所做的更改),但仍存在竞争条件。这已经在多次解释过:预/后增量不是原子的。

volatile的唯一副作用是"刷新"缓存,以便所有其他方看到数据的最新版本。在大多数情况下,这太严格了;这就是为什么volatile不是默认值的原因。

volatile没有同步(2)

volatile int i = 0;
void incIBy5() {
  i += 5;
}

和上面的问题一样,但是更糟糕的是因为i并不是私有的(private)。竞争条件仍然存在。为什么会出现这个问题呢?比如说,如果两个线程同时运行这段代码,输出可能是+ 5或者+ 10。然而,你可以确保看到变化。

多个独立的synchronized

void incIBy5() {
  int temp;
  synchronized(i) { temp = i }
  synchronized(i) { i = temp + 5 }
}

惊喜!这段代码也是错误的。事实上,它是完全错误的。首先,你正在同步i,而i即将被更改(此外,i是一个原始类型,所以我猜你正在同步一个通过自动装箱创建的临时Integer......)完全有缺陷。你也可以这样写:

synchronized(new Object()) {
  //thread-safe, SRSLy?
}

两个线程不能使用相同的锁进入同一个 synchronized 块。在这种情况下(以及您的代码中类似的情况),每次执行时锁对象都会发生更改,因此 synchronized 实际上没有任何效果。

即使您已经使用了 final 变量(或 this)进行同步,代码仍然是不正确的。两个线程可以先将 i 读取到 temp 中同步地(在 temp 中具有相同的局部值),然后第一个线程将新值分配给 i(例如从 1 到 6),另一个线程也做同样的事情(从 1 到 6)。

同步必须从读取到分配值跨度。您的第一个同步没有效果(读取 int 是原子性的),第二个同样如此。在我看来,以下是正确的形式:

void synchronized incIBy5() {
  i += 5 
}

void incIBy5() {
  synchronized(this) {
    i += 5 
  }
}

void incIBy5() {
  synchronized(this) {
    int temp = i;
    i = temp + 5;
  }
}

18
我唯一要补充的是,JVM会将变量值复制到寄存器中进行操作。这意味着在单个CPU/核心上运行的线程仍然可以看到非易失性变量的不同值。 - David Harkness
1
@thomsasz:好的,我看了这个链接上Jon Skeet回答的问题,他说“线程在读取volatile变量时必须检查是否有其他线程执行了写操作。”但是如果一个线程正在写操作中,而第二个线程正在读取它,会发生什么呢?我错了吗?这不是原子操作的竞态条件吗? - hardik
3
@Hardik:请创建另一个问题以获得更多有关您提出的问题的回答。在这里只有您和我,评论不适合提问。请不要忘记在此处发布新问题的链接,以便我可以跟进。 - Tomasz Nurkiewicz
使用 synchronized (this) 不是一个好主意,因为它会阻塞该对象中的任何其他执行。最好创建一个 final 对象 objLock 并使用 synchronize (objLock) 代替。 - Juan Acevedo
多个独立同步的示例缺少“i”定义代码。 - chill appreciator
显示剩余7条评论

87

将一个变量声明为volatile意味着修改它的值会立即影响到变量实际存储在内存中的位置。编译器无法优化任何对该变量的引用。这保证了当一个线程修改变量时,所有其他线程立即看到新值。(对于非volatile变量,这不能保证。)

声明一个atomic变量保证对该变量进行的操作是以原子方式发生的,即操作的所有子步骤在执行它们的线程中都已经完成,并且不会被其他线程中断。例如,一个增加和测试操作需要将变量递增,然后与另一个值进行比较;原子操作保证这两个步骤将被完成,就好像它们是一个单独不可分割/不可中断的操作。

同步访问变量允许只有一个线程同时访问变量,并强制所有其他线程等待该访问线程释放其对变量的访问。

同步访问类似于原子访问,但原子操作通常在更低级别的编程中实现。此外,完全可以同步某些访问变量,并允许其他访问不同步(例如,同步对变量的所有写入,但不同步其读取)。

原子性、同步和易变性是独立的属性,但通常结合使用以强制实现正确的线程协作访问变量。

补充说明(2016年4月)

对变量的同步访问通常使用监视器信号量实现。这些是低级别的互斥机制,它们允许一个线程独占地控制变量或代码块,如果其他线程也尝试获取相同的互斥体,则强制所有其他线程等待。一旦拥有线程释放了互斥体,另一个线程可以依次获取互斥体。

补充说明(2016年7月)

同步发生在一个对象上。这意味着调用类的同步方法将锁定该调用的this对象。静态同步方法将锁定Class对象本身。

同样,进入同步块需要锁定该方法的this对象。

这意味着如果它们锁定的是不同的对象,则同步方法(或块)可能会在多个线程中同时执行,但是对于任何给定的单个对象,只能有一个线程执行同步方法(或块)。


42

volatile:

volatile 是一个关键字。它强制所有线程从主内存获取变量的最新值,而不是缓存。所有线程可以在没有锁的情况下同时访问 volatile 变量的值。

它减少了内存一致性错误。

何时使用:一个线程修改数据,其他线程必须读取最新的数据值。其他线程将在不更新数据的情况下采取某些操作。

AtomicXXX:

AtomicXXX 类支持单个变量的无锁线程安全编程。

这些 AtomicXXX 类(如 AtomicInteger)解决了内存不一致性错误。

何时使用:多个线程可以读取和修改数据。

synchronized:

synchronized是用于保护方法或代码块的关键字。通过将方法设置为同步,您可以实现两个目标。

  1. 在同一对象上执行的两个synchronized方法永远不会同时运行
  2. 对象状态的更改对其他线程可见

何时使用:多个线程可以读取和修改数据。您的业务逻辑不仅更新数据,还执行原子操作

AtomicXXX相当于volatile + synchronized,尽管实现方式不同。

AmtomicXXX扩展了volatile变量+compareAndSet方法,但不使用同步。


3
这是第一个实际提到所描述的关键字/特性中发生前语义的答案,这在理解它们如何实际影响代码执行方面非常重要。得票更高的答案忽略了这一点。 - jhyot

5

我知道两个线程不能同时进入同步块

两个线程不能同时在同一个对象的同步块中进入两次。这意味着两个线程可以在不同的对象上进入相同的块。这种混淆可能会导致以下代码:

private Integer i = 0;

synchronized(i) {
   i++;
}

由于每次可能锁定不同的对象,因此它不会按预期运行。

如果是这样,那么atomic.incrementAndGet()如何在没有同步的情况下工作?是否线程安全?

是的。它不使用锁来实现线程安全。

如果您想更详细地了解它们的工作原理,可以阅读它们的代码。

内部读写易失变量 / 原子变量有什么区别?

Atomic类使用volatile字段。在字段上没有区别。区别在于执行的操作。原子类使用CompareAndSwap或CAS操作。

我在一些文章中读到过线程具有本地变量的副本,这是什么意思?

我只能假设它指的是每个CPU都有自己缓存的内存视图,这可能与每个其他CPU不同。为确保CPU具有数据的一致视图,您需要使用线程安全技术。

只有在至少一个线程更新共享内存时才存在此问题。


3

同步 Vs 原子 Vs 易失性:

  • 易失性和原子性只适用于变量,而同步适用于方法。
  • 易失性确保可见性,但不保证对象的原子性/一致性,而其他两者都保证可见性和原子性。
  • 易失性变量存储在RAM中,访问速度更快,但如果没有使用同步关键字,我们无法实现线程安全或同步。
  • 同步实现为同步块或同步方法,而其他两者不是。我们可以通过使用同步关键字使多行代码线程安全,而使用其他两者则无法实现相同的效果。
  • 同步可以锁定同一个类对象或不同的类对象,而其他两者不能。

如果我漏掉了什么,请纠正我。


1

volatile + synchronization是一个绝对可靠的解决方案,用于使包含多个指令的操作(语句)完全原子化。

例如:volatile int i = 2; i++,其实就是i = i + 1; 在执行这个语句后,i在内存中的值变成了3。这包括从内存中读取i的现有值(即2),将其加载到CPU累加器寄存器中,并通过将现有值增加一(2 + 1 = 3在累加器中)进行计算,然后将递增后的值写回内存。虽然i的值是volatile的,但这些操作不够原子化。i是volatile保证了只有一个读/写操作是原子的,而不是多个操作。因此,我们需要在i++周围也同步,以使其成为绝对可靠的原子语句。请记住,一个语句包括多个语句。

希望解释足够清楚。


1
Java中的volatile修饰符是确保线程间通信的一种特殊机制。当一个线程写入一个volatile变量时,另一个线程看到这个写操作时,第一个线程告诉第二个线程,它在执行对该volatile变量的写操作之前的所有内存内容。 原子操作是在单个任务单位中执行的,没有其他操作的干扰。在多线程环境中,原子操作是必需的,以避免数据不一致性。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接