[Java Concurrency in Practice]第十六章 Java内存模型

Java内存模型

一些高层设计问题,例如安全发布,同步策略的规范以及一致性等,它们的安全性都来自于JMM,并且当你理解了这些工作机制的原理后,就能更容易地使用它们。本章将介绍Java内存模型的底层需求以及所提供的保证,此外还将介绍一些高层设计原则背后的原理。

16.1 什么是内存模型,为什么需要它

假设一个线程为变量aVailable赋值:
aVailable = 3;
内存模型需要解决这个问题:”在什么条件下,读取aVailable的线程将看到这个值为3?“,如果缺少同步,那么将会有许多因素使得线程无法立即甚至永远看到一个线程的操作结果。在编译器中生成的指令顺序,可以与源代码中的顺序不同,此外编译器还会还会把变量保存在寄存器而不是内存中;处理器可以采用乱序或并行的方式来执行指令;缓存可能会改变将写入变量提交到主内存的次序;而且,保存在处理器本地缓存中的值,对于其他处理器是不可见的。这些因素都会使得一个线程无法看到变量的最新值,并且会导致其他线程中的内存操作似乎在乱序执行——如果没有使用正确的同步。

在单线程环境中,我们无法看到所有这些底层技术,它们除了提高程序的执行速度外,不会产生其他影响。Java语言规范要求JVM在线程中维护一种类似串行的语义:只要程序的最终结果与在严格串行环境中执行的结果相同,那么上述所有操作都是允许的。

在多线程环境中,维护程序的串行性将导致很大的性能开销。对于并发应用程序中的线程来说,它们在大部分时间里都执行各自的任务,因此在线程之间的协调操作只会降低应用程序的运行速度,而不会带来任何好处。只有当多个线程要共享数据时,才必须协调它们之间的操作,并且JVM依赖程序通过同步操作来找出这些协调操作将在何时发生。

JMM规定了JVM必须遵循一组最小保证,这组保证规定了对变量的写入操作在何时将对其他线程可见。

16.1.1 平台的内存模型

在共享内存的多处理器体系架构中,每个处理器都拥有自己的缓存,并且定期地与主内存进行协调。在不同的处理器架构中提供了不同级别的缓存一致性(Cache Coherence),其中一部分只提供最小的保证,即允许不同的处理器在任意时刻从同一个存储位置上看到不同的值。操作系统、编译器以及运行时(有时甚至包括应用程序)需要弥补这种在硬件能力与线程安全需求之间的差异。

要想确保每个处理器都能在任意时刻知道其他处理器正在进行的工作,将需要非常大的开销。在大多数时间里,这种信息是不必要的,因此处理器会恰当放宽存储一致性保证,以换取性能的提升。在架构定义的内存模型中将告诉应用程序可以从内存系统中获得怎样的保证,此外还定义了一些特殊的指令(称为内存栅栏或栅栏),当需要共享数据时,这些指令就能实现额外的存储协调保证。为了使Java开发人员无须关心不同架构上内存模型之间的差异,Java还提供了自己的内存模型,并且JVM通过在恰当的位置上插入内存栅栏来屏蔽在JMM与底层平台内存模型之间的差异。

程序执行一种简单假设:想象在程序中只存在唯一的操作执行顺序,而不考虑这些操作在何种处理器上执行,并且在每次读取变量时,都能获得在执行序列中(任何处理器)最近一次写入该变量的值。这种乐观的模型就称为串行一致性。在任何一款现代的处理器架构中都不会提供这种串行一致性,JMM也是如此。

在现代支持共享内存的多处理器(和编译器)中,当跨线程共享数据时,会出现一些奇怪的情况,除非通过使用内存栅栏来防止这些情况的发生。幸运的是,Java程序不需要指定内存栅栏的位置,而只需通过正确地使用同步来找出何时将访问共享状态

16.1.2 重排序(reordering)

在没有充分同步的程序中,如果调度器采用不恰当的方式来交替执行不同线程的操作,那么将导致不正确的结果。更糟的是,JMM还使得不同线程看到的操作执行顺序是不同的,从而导致在缺乏同步的情况下,要推断操作的执行顺序将变得更加复杂。各种使操作延迟或者看似乱序执行的不同原因,都可以归为重排序。

Java语言规范规定了JVM要维护内部线程类似顺序化语意:只要程序的最终结果等同于它在严格的顺序化环境中执行的结果,那么对指令的重新排序的行为是允许的。

下面程序说明了,在没有正确同步的情况下,即使要推断最简单的并发程序也是非常困难的,PossibleReordering可能输出(1,0)或(1,1)或(1,1):线程A可以在线程B开始之前就执行完成,线程B也可以在线程A开始之前执行完成,或者二者的操作交替执行。但奇怪的是,PossibleReordering还可以输出(0,0)。由于每个线程的各个操作之间不存在数据流依赖性,因此这些操作可以乱序执行。(即使这些操作按照顺序执行,但在将缓存刷新到主内存的不同时序中也可能出现这种情况,从线程B的角度看,线程A中的赋值操作可能以相反的次序执行。)

public class PossibleReordering {
    static int x = 0, y = 0;
    static int a = 0, b = 0;

    public static void main(String[] args)
            throws InterruptedException {
        Thread one = new Thread(new Runnable() {
            public void run() {
                a = 1;
                x = b;
            }
        });
        Thread other = new Thread(new Runnable() {
            public void run() {
                b = 1;
                y = a;
            }
        });
        one.start(); other.start();
        one.join();   other.join();
        System.out.println("( "+ x + "," + y + ")");
    }
}

下图给出了一种可能由重排序导致的交替执行方式,这种情况下会输出(0,0):

PossibleReordering是一个简单程序,但要列举出它所有可能的结果却非常困难。内存级的重排序会使程序的行为变得不可预测。如果没有同步,那么推断出执行顺序将是非常困难的,而要确保在程序中正确地使用同步却是非常容易的。同步将限制编译器、运行时和硬件对内存操作重排序的方式,从而在实施重排序时不会破坏JMM提供的可见性保证。

16.1.3 Java内存模型简介

Java内存模型是通过各种操作来定义的,包括对变量的读/写操作,监视器的加锁和释放锁操作,以及线程的启动和合并操作。

JMM为程序中所有的操作定义了一个偏序关系,称之为Happens-Before。要想保证执行操作B的线程看到操作A的结果(无论A和B是否在同一个线程中执行),那么在A和B之间必须满足Happens-Before关系。如果两个操作之间缺乏Happens-Before关系,那么JVM可以对它们任意地重排序。
(偏序只对部分元素成立关系R,全序对集合中任意两个元素都有关系R。例如:集合的包含关系就是半序,也就是偏序,因为两个集合可以互不包含;而实数中的大小关系是全序,两个实数必有一个大于等于另一个;又如:复数中的大小就是半序,虚数不能比较大小。)

当一个变量被多个线程读取并且至少被一个线程写入时,如果在读操作和写操作之间没有依照Happens-Before来排序,那么就会产生数据竞争问题。在正确同步的程序中不存在数据竞争,并会表现出串行一致性,这意味着程序中的所有操作都会按照一种固定的和全局的顺序执行。

Happens-Before的规则包括:
程序顺序规则。如果程序中操作A在操作B之前,那么在线程中A操作将在B操作之前执行。
监视器锁规则。在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前执行。(显式锁和内置锁在加锁和解锁等操作上有着相同的内存语义)
volatile变量规则。对volatile变量的写入操作必须在对该变量的读操作之前执行。(原子变量与volatile变量在读操作和写操作上有着相同的语义)
线程启动规则。在线程上对Thread.start的调用必须在该线程中执行任何操作之前执行。
线程结束规则。线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从Thread.join中成功返回,或者在调用Thread.isAlive时返回false。
中断规则。当一个线程在另一个线程上调用interrupt时,必须在被中断线程检测到interrupt调用之前执行(通过抛出InterruptException,或者调用isInterrupted和interrupted)。
终结器规则。对象的构造函数必须在启动该对象的终结器之前执行完成。
传递性。如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。

虽然这些操作只满足偏序关系,但同步操作,如锁的获取与释放等操作,以及volatile变量的读取与写入操作,都满足全序关系。因此,在描述Happens-Before关系时,就可以使用”后续的锁获取操作“和”后续的volatile变量的读取操作“等表达术语。

下图给出了当两个线程使用同一个锁进行同步时,在它们之间的Happens-Before关系。在线程A内部的所有操作都按照它们在源程序中的先后顺序来排序,在线程B内部的操作也是如此。由于A释放了锁M,并且B随后获得了锁M,因此A中所有在时候放锁之前的操作,也就位于B中请求锁之后的所有操作之前。如果这两个线程是在不同的锁上进行同步的,那么就不能推断它们之间的动作顺序,因为在这两个线程的操作之间并不存在Happens-Before关系。

16.1.4 借助同步

由于Happens-Before的排序功能很强大,因此有时候可以”借助(Piggyback)“现有同步机制的可见性属性。这需要将Happens-Before的程序顺序规则与其他某个顺序规则(通常是监视器锁规则或者volatile变量规则)结合起来,从而对某个未被锁保护的变量的访问操作进行排序。这项技术由于对语句的顺序非常敏感,因此很容易出错。它是一项高级技术,并且只有当需要最大限度地提升某些类(例如ReentrantLock)的性能时,才应该使用这项技术。

在FutureTask的保护方法AbstractQueuedSynchronizer中说明了如何使用这种”借助“技术。AQS维护了一个表示同步器状态的整数,FutureTask用这个整数来保存任务的状态:正在运行,已完成和已取消。但FutureTask还维护了其他一些变量,例如计算的结果。当一个线程调用set来保存结果并且另一个线程调用get来获取该结果时,这两个线程最好按照Happens-Before进行排序。这可以通过将执行结果的引用声明为volatile类型来实现,但利用现有的同步机制可以更容易地实现相同的功能。

FutureTask在设计时能够确保,在调用tryAcquireShared之前总能成功地调用tryReleaseShared。tryReleaseShared会写入一个volatile类型的变量,而tryAcquireShared将读取这个变量。下面程序清单给出了innerSet和innerGet等方法,在保存和获取result时将调用这些方法。由于innerSet将在调用releaseShared(这又将调用tryReleaseShared)之前写入result,并且innerGet将在调用acquireShared(这又将调用tryReleaseShared)之后读取result,因此将程序顺序规则与volatile变量规则结合在一起,就可以确保innerSet中的写入操作在innerGet中的读取操作之前。

    private final class Sync extends AbstractQueuedSynchronizer {
        /** State value representing that task is running */
        private static final int RUNNING   = 1;
        /** State value representing that task ran */
        private static final int RAN       = 2;
        /** State value representing that task was cancelled */
        private static final int CANCELLED = 4;

        /** The result to return from get() */
        private V result;
        /** The exception to throw from get() */
        private Throwable exception;

        void innerSet(V v) {
        for (;;) {
        int s = getState();
        if (s == RAN)
            return;
                if (s == CANCELLED) {
            // aggressively release to set runner to null,
            // in case we are racing with a cancel request
            // that will try to interrupt runner
                    releaseShared(0);
                    return;
                }
        if (compareAndSetState(s, RAN)) {
                    result = v;
                    releaseShared(0);
                    done();
            return;
                }
            }
        }

        V innerGet() throws InterruptedException, ExecutionException {
            acquireSharedInterruptibly(0);
            if (getState() == CANCELLED)
                throw new CancellationException();
            if (exception != null)
                throw new ExecutionException(exception);
            return result;
        }
    }

之所以将这项技术称为“借助”,是因为它使用了一种现有的Happens-Before顺序来确保对象X的可见性,而不是专门为了发布X而创建一种Happens-Before顺序。

在FutureTask中使用的“借助”技术很容易出错,因此要谨慎使用。但在某些情况下,这种“借助”技术是非常合理的。例如,当某个类在其规范中规定它的各个方法之间必须遵守一种Happens-Before关系,基于BlockingQueue实现的安全发布就是一种“借助”。如果一个线程将对象置入队列并且另一个线程随后获取这个对象,那么这就是一种安全发布,因为在BlockingQueue的实现中包含有足够的内部同步来确保入列操作在出列操作之前执行。

在类库中提供的其他Happens-Before排序包括:

  • 将一个元素放入一个线程安全容器的操作将在另一个线程从该容器中获得这个元素的操作之前执行。
  • 在CountDownLatch上的倒数操作将在线程从闭锁上的await方法返回之前执行。
  • 释放Semaphore许可的操作将在从该Semaphore上获得一个许可之前执行。
  • Future表示的任务的所有操作将在从Future.get中返回之前执行。
  • 向Executor提交一个Runnable或Callable的操作将在任务开始执行之前执行。
  • 一个线程到达CyclicBarrier或Exchange的操作将在其他到达该栅栏或交换点的线程被释放之前执行。如果CyclicBarrier使用一个栅栏操作,那么到达栅栏的操作将在栅栏操作之前执行,而栅栏操作又会在线程从栅栏中释放之前执行。

16.2 发布

第三章介绍了如何安全地或者不正确地发布一个对象。对于其中介绍的各种安全技术,它们的安全性都来自于JMM提供的保证,而造成不正确发布的真正原因,就是在”发布一个共享对象“与”另一个线程访问该对象“之前缺少一种Happens-Before排序。

16.2.1 不安全的发布

当缺少Happens-Before关系时,就可能出现重排序问题,这就解释了为什么在没有充分同步的情况下发布一个对象会导致另一个线程看到一个只被部分构造的对象。在初始化一个新的对象时需要写入多个变量,即新对象中的各个域。同样,在发布一个引用时也需要写入一个变量,即新对象的引用。如果无法确保发布共享引用的操作在另一个线程加载该共享引用之前执行,那么对新对象引用的写入操作将与对象中各个域的写入操作重排序(从使用该对象的线程的角度来看)。在这种情况下,另一个线程可能看到对象引用的最新值,但同时也将看到对象的某些或全部状态中包含的是无效值,即一个被部分构造对象。

错误的延迟初始化将导致不正确的发布。初看起来,在程序中存在的问题只有竞态条件问题。在某些特定条件下,例如当Resource的所有实例都相同时,你或许会忽略这些问题(以及在多次创建Resource实例时存在的低效率问题)。然而,即使不考虑这些问题,UnsafeLazyInitialization仍然是不安全的,因为另一个线程可能看到对部分构造的Resource实例的引用。

//不安全的惰性初始化
public class UnsafeLazyInitialization {
    private static Resource resource;

    public static Resource getInstance() {
        if (resource == null)
            resource = new Resource();  // 不安全的发布
        return resource;
    }
}

假设线程A是第一个调用getInstance的线程。它将看到resource为null,并且初始化一个新的Resource,然后将resource设置为执行这个新实例。当线程B随后调用getInstance,它可能看到resource的值为非空,因此使用这个已经构造好的Resource。最初这看不出任何问题,但线程A写入resource的操作与线程B读取resource的操作之间不存在Happens-Before关系。在发布对象时存在数据竞争问题,因此B并不一定能看到Resource的正确状态。

当新分配一个Resource时,Resource的构造函数将把新实例中的各个域由默认值(由Object构造函数写入的)修改为它们的初始值。由于在两个线程中都没有使用同步,因此线程B看到的线程A中的操作顺序,可能与线程A执行这些操作时的顺序并不相同。因此,即使线程A初始化Resource实例之后再将resource设置为指向它,线程B仍可能看到对resource的写入操作将在对Resource各个域的写入操作之前发生。因此,线程就可能看到一个被部分构造的Resource实例,该实例可能处于无效状态,并在随后该实例的状态可能出现无法预料的变化。

除了不可变对象之外,使用被另一个线程初始化的对象通常是不安全的,除非对象的发布操作是在使用该对象的线程开始使用之前执行。

16.2.2 安全的发布

第三章介绍的安全发布常用模式可以确保被发布对象对于其他线程是可见的,因为它们保证发布对象的操作将在使用对象的线程开始使用该对象的引用之前执行。(因为它们保证发布对象happens-before于消费线程加载已发布对象的引用。)

借助于类库中现在的同步容器、使用锁保护共享变量、或都使用共享的volatile类型变量,都可以保证对该变量的读取和写入是按照happens-before排序的。

happens-before事实上可以比安全发布承诺更强的可见性与排序性。如果X对象从A线程到B线程是安全发布的,安全发布可以保证X本身的状态是可见的,但是不包括A所依赖的其他对象(即A中还包含了其他对象,不只是基本类型变量)的状态;但是如果X对象是在同步的情况下由线程A生产,然后由线程B消费,B不仅仅能看到A中所有直接状态域,而且还能看见A所依赖的甚至是更深一层的状态域所做的更改。

16.2.3 安全初始化模式

有时候,我们需要推迟一些高开销的对象的初始化操作,并且只有当使用这些对象时才进行初始化,但我们也看到了误用延迟初始化时导致的问题(不安全的发布)。通过将getResource方法声明为synchronized,可以修复UnsafeLazyInitialization中的问题。由于getInstance的代码路径很短(只包括一个判断预见和一个预测分支),因此如果getInstance没有被多个线程频繁调用,那么在SafeLazyInitialization上不会存在激烈的竞争,从而能提供令人满意的性能。

@ThreadSafe
public class SafeLazyInitialization {
    private static Resource resource;

    public synchronized static Resource getInstance() {
        if (resource == null)
            resource = new Resource();
        return resource;
    }

    static class Resource {
    }
}

使用静态域来初始化,会有的额外的线程安全性保证。静态初始化是JVM完成的,发生在类的初始化阶段(加载、链接、类的初始化),即类被加载后到类被任意线程使用之前。JVM会在初始化期间获得一个锁,这个锁每个线程都至少会获取一次,来确保类是否已被加载;这个锁也保证了静态初始化期间,内存写入的结果自动地对照所有线程都是可见的。所以静态初始化的对象,无论是构造期间还是被引用的时候,都不需要显试地进行同步。然而,这仅仅适用于构造当时的状态——如果对象是可变的,为了保证后续修改的可见性,仍然需要同步。

@ThreadSafe//主动初始化
public class EagerInitialization {
    private static Resource resource  = new Resource();
    public static Resource getResource() { return resource; }
}

主动初始化,避免了懒汉式每次调用getInstance时引发的同步开销,如下面:

@ThreadSafe
public class SafeLazyInitialization {
    private static Resource resource;
    public  synchronized  static Resource getInstance() {
        if (resource == null)
            resource = new Resource();
        return resource;
    }
}

将主动初始(饿汉式)技术与JVM的惰性类加载相结合,创建出一种惰性初始化技术:

@ThreadSafe
public class ResourceFactory {
     private static class ResourceHolder {
         public static Resource resource = new Resource();
     }

     public static Resource getResource() {
         return  ResourceHolder.resource ;
     }
}

通过使用提前初始化(Eager Initialization),避免了在每次调用SafeLazyInitialization中的getInstance时所产生的同步开销。通过将这项技术和JVM的延迟加载机制结合起来,可以形成一种延迟初始化技术,从而在常见的代码路径中不需要同步。在下的”延迟初始化占位类模式“中使用了一个专门的类来初始化Resource。JVM将推迟ResourceHolder的初始化操作,知道开始使用这个类时才初始化,并且由于通过一个静态初始化来初始化Resource,因此不需要额外的同步。当任何一个线程第一次调用getResource时,都会使ResourceHolder被加载和被初始化,此时静态初始化器执行Resource的初始化操作。

16.2.4 双重检查加锁

在早期的JVM中,同步(甚至是无竞争的同步)都存在这巨大的性能开销。因此,人们想出了许多“聪明的(或者至少看上去聪明)”技巧来降低同步的开销,有些技巧很好,但也有些技巧是不好的,甚至是糟糕的,DCL就属于“糟糕”的一类。

@NotThreadSafe
public class DoubleCheckedLocking {
    private static Resource resource;

    public static Resource getInstance() {
        if (resource == null) {
            synchronized (DoubleCheckedLocking.class) {
                if (resource == null)
                    resource = new Resource();
            }
        }
        return resource;
    }

    static class Resource {

    }
}

由于早期的JVM在性能上存在一些有待优化的地方,因此延迟初始化经常被用来避免不必要的高开销操作,或者降低程序的启动时间。在编写正确的延迟初始化方法中需要使用同步。但在当时,同步不仅执行速度很慢,并且更重要的是,开发人员还没有完全理解同步的含义:虽然人们能很好地理解“独占性”的含义,但却没有很好地理解“可行性”的含义。

DCL声称能实现两全其美——在常见代码路径上的延迟初始化中不存在同步开销。它的工作原理是,首先检查是否在没有同步的情况下需要初始化,如果resource引用不为空,那么就直接使用它。否则,就进行同步并再次检查Resource是否被初始化,从而保证只有一个线程对共享的Resource执行初始化。在常见的代码路径中——获取一个已构造好的Resource引用,并没有使用同步。这就是问题所在,线程可能看到一个仅被部分构造的Resource。

DCL的真正问题在于:当在没有同步的情况下读取一个共享对象时,可能发生的最糟糕事情只是看到一个失效值(在这种情况下是一个空值),此时DCL方法将通过在持有锁的情况下再次尝试避免这种风险。然而,实际情况远比这种情况糟糕——线程可能看到引用的当前值,但对象的状态值却是失效的,这意味着线程可以看到对象处于无效或错误的状态。

在JMM的后续版本(Java5.0以及更高的版本)中,如果把resource声明为volatile类型,那么就能启用DCL,并且这种方式对性能影响很小,因为volatile变量读取操作的性能通常只是略高于非volatile变量的读取操作的性能。然而,DCL的这种使用方法已经被广泛地废弃了——促使该模式出现的驱动力(无竞争同步的执行速度很慢,以及JVM启动时很慢)已经不复存在,因而它不是一种高效地优化措施。延迟初始化占位类模式能带来同样的优势,并且更容易理解。

16.3 初始化过程中的安全性

如果能确保初始化过程的安全性,那么就可以使得被正确构造的不可变对象在没有同步的情况下也能安全地在多个线程之间共享,而不管它们是如何发布的,甚至通过某种数据竞争来发布。(这意味着,如果Resource是不可变的,那么UnsafeLazyInitialization实际上是安全的。)

如果不能确保初始化的安全性,那么当在发布或线程中没有使用同步时,一些本应为不可变对象(例如String)的值将会发生改变。(为了确保初始化的安全,所以在1.5中String中的域都已修改成了final了)

初始化安全性将确保,对于被正确构造的对象,所有线程都能看到由构造函数为对象给各个final域设置的正确值,而不管采用何种方式来发布对象。而且对于可以通过被正确构造对象中某个final域到达的任意变量(例如某个final数组中的元素,或者由一个final域引用的HashMap的内容)将同样对于其他线程是可见的。(这仅仅适用于那些在构造过程中从对象的final域出发可以到达的对象)

对于含有final域的对象,安全初始化禁止了在构造器中将final域写回到主存动作与获取引用动作间的重排序,会按照程序的语义——先构造,再引用的,的顺序先去构造完对象,然后再获取该对象的引用,而final域又是在构造器中初始化的,而JVM内存模型又对final作出了明确的可见性规定——当构造完后final域对其他线程一定是可见的,所以final写回主存的动作会happens-before于获取对象的引用。

初始化安全性意味着,下面的SafeStates可以安全地发布,即便通过不安全的延迟初始化,或者在没有同步的情况下将SafeStates的引用放到一个公有的静态域,或者没有使用同步以及依赖于非线程安全的HashSet。

@ThreadSafe
public class SafeStates {
    private final Map<String, String> states;

    public SafeStates() {
        states = new HashMap<String, String>();
        states.put("alaska", "AK");
        states.put("alabama", "AL");
        /*...*/
        states.put("wyoming", "WY");
    }

    public String getAbbreviation(String s) {
        return states.get(s);
    }
}

然而,许多对SafeStates的细微修改都可能破坏它的线程安全性。如果states不是final类型,或者存在除构造函数以外的其他方法能修改states,那么初始化安全性将无法确保在缺少同步的情况下安全地访问SafeStates。如果在SafeStates中还有其他的非final域,那么其他线程仍然可能看到这些域上的不正确的值。这也导致了对象在构造函数中逸出,从而使初始化安全性的保证无效。

初始化安全性只能保证通过final域可达的值从构造过程完成时可见性。对于通过非final域可达的值,或者在构成过程完成后可能改变的值,必须采用同步来确保可见性。

小结

Java内存模型说明了某个线程的内存操作在哪些情况下对于其他线程是可见的。其中包括确保这些操作是按照一种Happens-Before的偏序关系进行排序,而这种关系是基于内存操作和同步操作等级别来定义的。如果缺少充足的同步,那么当线程访问共享数据时,会发生一些非常奇怪的问题。然而,如果使用第二章与第三章介绍的更高级规则,例如安全发布,那么即使不考虑Happens-Before的底层细节,也能确保线程安全性。

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值