Java并发编程实战系列16之Java内存模型(JMM)

简介: 前面几章介绍的安全发布、同步策略的规范还有一致性,这些安全性都来自于JMM。16.1 什么是内存模型,为什么需要它?假设a=3内存模型要解决的问题是:“在什么条件下,读取a的线程可以看到这个值为3?”如果缺少同步会有很多因素导致无法立即...

前面几章介绍的安全发布、同步策略的规范还有一致性,这些安全性都来自于JMM。

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

假设

a=3

内存模型要解决的问题是:“在什么条件下,读取a的线程可以看到这个值为3?”

如果缺少同步会有很多因素导致无法立即、甚至永远看不到一个线程的操作结果,包括

  • 编译器中指令顺序
  • 变量保存在寄存器而不是内存中
  • 处理器可以乱序或者并行执行指令
  • 缓存可能会改变将写入变量提交到主内存的次序
  • 处理器中也有本地缓存,对其他处理器不可见

单线程中,会为了提高速度使用这些技术,但是Java语言规范要求JVM在线程中维护一种类似串行的语义:只要程序的最终结果与在严格环境中的执行结果相同,那么上述操作都是允许的。

随着处理器越来越强大,编译器也在不断的改进,通过指令重排序实现优化执行,使用成熟的全局寄存器分配算法,但是单处理器存在瓶颈,转而变为多核,提高并行性。

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

JMM规定了JVM必须遵循一组最小的保证,保证规定了对变量的写入操作在何时将对其他线程可见。JMM需要在各个处理器体系架构中实现一份。

16.1.1 平台的内存模型

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

要确保每个处理器在任意时刻都知道其他处理器在进行的工作,这将开销巨大。多数情况下,这完全没必要,可随意放宽存储一致性,换取性能的提升。存在一些特殊的指令(成为内存栅栏),当需要共享数据时,这些指令就能实现额外的存储协调保证。为了使Java开发人员无须关心不同架构上内存模型之间的差异,产生了JMM,JVM通过在适当的位置上插入内存栅栏来屏蔽JMM与底层平台内存模型之间的差异。

按照程序的顺序执行,这种乐观的串行一致性在任何一款现代多处理器架构中都不会提供这种串行一致性。当跨线程共享数据时,会出现一些奇怪的情况,除非通过使用内存栅栏来防止这种情况的发生。

16.1.2 重排序

下面的代码,4中输出都是有可能的。

public class ReorderingDemo {

    static int x = 0, y = 0, a = 0, b = 0;

    public static void main(String[] args) throws Exception {
        Bag bag = new HashBag();
        for (int i = 0; i < 10000; i++) {
            x = y = a = b = 0;
            Thread one = new Thread() {
                public void run() {
                    a = 1;
                    x = b;
                }
            };
            Thread two = new Thread() {
                public void run() {
                    b = 1;
                    y = a;
                }
            };
            one.start();
            two.start();
            one.join();
            two.join();
            bag.add(x + "_" + y);
        }
        System.out.println(bag.getCount("0_1"));
        System.out.println(bag.getCount("1_0"));
        System.out.println(bag.getCount("1_1"));
        System.out.println(bag.getCount("0_0"));
        // 结果是如下的或者其他情况,证明可能发生指令重排序
        //        9999
        //        1
        //        0
        //        0

        //        9998
        //        2
        //        0
        //        0
    }

16.1.3 Java内存模型简介

JMM通过各种操作来定义,包括对变量的读写操作,监视器monitor的加锁和释放操作,以及线程的启动和合并操作,JMM为程序中所有的操作定义了一个偏序关系,成为Happens-before,要想保证执行操作B的线程看到A的结果,那么A和B之间必须满足Happens-before关系。如果没有这个关系,JVM可以任意的重排序。

JVM来定义了JMM(Java内存模型)来屏蔽底层平台不同带来的各种同步问题,使得程序员面向JAVA平台预期的结果都是一致的,对于“共享的内存对象的访问保证因果性正是JMM存在的理由”(这句话说的太好了!!!)。

因为没法枚举各种情况,所以提供工具辅助程序员自定义,另外一些就是JMM提供的通用原则,叫做happens-before原则,就是如果动作B要看到动作A的执行结果(无论A/B是否在同一个线程里面执行),那么A/B就需要满足happens-before关系。下面是所有的规则,满足这些规则是一种特殊的处理措施,否则就按照上面背景提到的对于可见性、顺序性是没有保障的,会出现“意外”的情况。

如果多线程写入遍历,没有happens-before来排序,那么会产生race condition。在正确使用同步的的程序中,不存在数据竞争,会表现出串行一致性。

  • (1)同一个线程中的每个Action都happens-before于出现在其后的任何一个Action。//控制流,而非语句
  • (2)对一个监视器的解锁happens-before于每一个后续对同一个监视器的加锁。//lock、unlock
  • (3)对volatile字段的写入操作happens-before于每一个后续的同一个字段的读操作。
  • (4)Thread.start()的调用会happens-before于启动线程里面的动作。
  • (5)Thread中的所有动作都happens-before于其他线程检查到此线程结束或者Thread.join()中返回或者Thread.isAlive()==false。
  • (6)一个线程A调用另一个另一个线程B的interrupt()都happens-before于线程A发现B被A中断(B抛出异常或者A检测到B的isInterrupted()或者interrupted())。
  • (7)一个对象构造函数的结束happens-before与该对象的finalizer的开始
  • (8)如果A动作happens-before于B动作,而B动作happens-before与C动作,那么A动作happens-before于C动作。

16.1.4 借助同步

piggyback(借助)现有的同步机制可见性。例如在AQS中借助一个volatile的state变量保证happens-before进行排序。

举例:Inner class of FutureTask illustrating synchronization piggybacking. (See JDK source)

还可以记住CountDownLatch,Semaphore,Future,CyclicBarrier等完成自己的希望。

16.2 发布

第三章介绍了如何安全的或者不正确的发布一个对象,其中介绍的各种技术都依赖JMM的保证,而造成发布不正确的原因就是

  • 发布一个共享对象
  • 另外一个线程访问该对象

之间缺少一种happens-before关系。

16.2.1 不安全的发布

缺少happens-before就会发生重排序,会造成发布一个引用的时候,和内部各个field初始化重排序,比如

init field a
init field b
发布ref
init field c

这时候从使用这角度就会看到一个被部分构造的对象。

错误的延迟初始化将导致不正确的发布,如下代码。这段代码不光有race condition、创建低效等问题还存储在另外一个线程会看到部分构造的Resource实例引用。

@NotThreadSafe
public class UnsafeLazyInitialization {
    private static Resource resource;

    public static Resource getInstance() {
        if (resource == null)
            resource = new Resource(); // unsafe publication
        return resource;
    }

    static class Resource {
    }
}

那么,除非使用final,或者发布操作线程在使用线程开始之前执行,这些都满足了happens-before原则。

16.2.2 安全的发布

使用第三章的各种技术可以安全发布对象,去报发布对象的操作在使用对象的线程开始使用对象的引用之前执行。如果A将X放入BlockingQueue,B从队列中获取X,那么B看到的X与A放入的X相同,实际上由于使用了锁保护,实际B能看到A移交X之前所有的操作。

16.2.3 安全的初始化模式

有时候需要延迟初始化,最简单的方法:

@ThreadSafe
public class SafeLazyInitialization {
    private static Resource resource;

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

    static class Resource {
    }
}

如果getInstance调用不频繁,这绝对是最佳的。

在初始化中使用static会提供额外的线程安全保证。静态初始化是由JVM在类的初始化阶段执行,并且在类被加载后,在线程使用前的。静态初始化期间,内存写入操作将自动对所有线程可见。因此静态初始化对象不需要显示的同步。下面的代码叫做eager initialization。

@ThreadSafe
public class EagerInitialization {
    private static Resource resource = new Resource();

    public static Resource getResource() {
        return resource;
    }

    static class Resource {
    }
}

下面是lazy initialization。JVM推迟ResourceHolder的初始化操作,直到开始使用这个类时才初始化,并且通过一个static来做,不需要额外的同步。

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

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

    static class Resource {
    }
}

16.2.4 双重检查加锁CDL

DCL实际是一种糟糕的方式,是一种anti-pattern,它只在JAVA1.4时代好用,因为早期同步的性能开销较大,但是现在这都不是事了,已经不建议使用。

@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 {

    }
}

初始化instance变量的伪代码如下所示:

memory = allocate();   //1:分配对象的内存空间
ctorInstance(memory);  //2:初始化对象
instance = memory;     //3:设置instance指向刚分配的内存地址

之所以会发生上面我说的这种状况,是因为在一些编译器上存在指令排序,初始化过程可能被重排成这样:

memory = allocate();   //1:分配对象的内存空间
instance = memory;     //3:设置instance指向刚分配的内存地址
                       //注意,此时对象还没有被初始化!
ctorInstance(memory);  //2:初始化对象

而volatile存在的意义就在于禁止这种重排!解决办法是声明为volatile类型。这样就可以用DCL了。

@NotThreadSafe
public class DoubleCheckedLocking {
    private static volatile Resource resource;

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

    static class Resource {

    }
}

16.3 初始化过程中的安全性

final不会被重排序。

下面的states因为是final的所以可以被安全的发布。即使没有volatile,没有锁。但是,如果除了构造函数外其他方法也能修改states。如果类中还有其他非final域,那么其他线程仍然可能看到这些域上不正确的值。也导致了构造过程中的escape。

写final的重排规则:

  • JMM禁止编译器把final域的写重排序到构造函数之外。
  • 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。也就是说:写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了。

读final的重排规则:

  • 在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。也就是说:读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。

如果final域是引用类型,那么增加如下约束:

  • 在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。(个人觉得基本意思也就是确保在构造函数外把这个被构造对象的引用赋值给一个引用变量之前,final域已经完全初始化并且赋值给了当前构造对象的成员域,至于初始化和赋值这两个操作则不确保先后顺序。)
@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);
    }
}
目录
相关文章
|
7天前
|
安全 Java 调度
Java编程时多线程操作单核服务器可以不加锁吗?
Java编程时多线程操作单核服务器可以不加锁吗?
21 2
|
5天前
|
Java
JAVA并发编程系列(13)Future、FutureTask异步小王子
本文详细解析了Future及其相关类FutureTask的工作原理与应用场景。首先介绍了Future的基本概念和接口方法,强调其异步计算特性。接着通过FutureTask实现了一个模拟外卖订单处理的示例,展示了如何并发查询外卖信息并汇总结果。最后深入分析了FutureTask的源码,包括其内部状态转换机制及关键方法的实现原理。通过本文,读者可以全面理解Future在并发编程中的作用及其实现细节。
|
6天前
|
Java 数据中心 微服务
Java高级知识:线程池隔离与信号量隔离的实战应用
在Java并发编程中,线程池隔离与信号量隔离是两种常用的资源隔离技术,它们在提高系统稳定性、防止系统过载方面发挥着重要作用。
6 0
|
9天前
|
Java 数据处理 调度
Java中的多线程编程:从基础到实践
本文深入探讨了Java中多线程编程的基本概念、实现方式及其在实际项目中的应用。首先,我们将了解什么是线程以及为何需要多线程编程。接着,文章将详细介绍如何在Java中创建和管理线程,包括继承Thread类、实现Runnable接口以及使用Executor框架等方法。此外,我们还将讨论线程同步和通信的问题,如互斥锁、信号量、条件变量等。最后,通过具体的示例展示了如何在实际项目中有效地利用多线程提高程序的性能和响应能力。
|
9天前
|
安全 算法 Java
Java中的多线程编程:从基础到高级应用
本文深入探讨了Java中的多线程编程,从最基础的概念入手,逐步引导读者了解并掌握多线程开发的核心技术。无论是初学者还是有一定经验的开发者,都能从中获益。通过实例和代码示例,本文详细讲解了线程的创建与管理、同步与锁机制、线程间通信以及高级并发工具等主题。此外,还讨论了多线程编程中常见的问题及其解决方案,帮助读者编写出高效、安全的多线程应用程序。
|
11天前
|
存储 缓存 Java
java线程内存模型底层实现原理
java线程内存模型底层实现原理
java线程内存模型底层实现原理
|
15天前
|
缓存 Java 应用服务中间件
Java虚拟线程探究与性能解析
本文主要介绍了阿里云在Java-虚拟-线程任务中的新进展和技术细节。
|
21天前
|
存储 缓存 安全
【Java面试题汇总】多线程、JUC、锁篇(2023版)
线程和进程的区别、CAS的ABA问题、AQS、哪些地方使用了CAS、怎么保证线程安全、线程同步方式、synchronized的用法及原理、Lock、volatile、线程的六个状态、ThreadLocal、线程通信方式、创建方式、两种创建线程池的方法、线程池设置合适的线程数、线程安全的集合?ConcurrentHashMap、JUC
【Java面试题汇总】多线程、JUC、锁篇(2023版)
|
13天前
|
Java 开发者
Java中的多线程基础与应用
【9月更文挑战第22天】在Java的世界中,多线程是一块基石,它支撑着现代并发编程的大厦。本文将深入浅出地介绍Java中多线程的基本概念、创建方法以及常见的应用场景,帮助读者理解并掌握这一核心技术。
|
9天前
|
Java 调度
Java-Thread多线程的使用
这篇文章介绍了Java中Thread类多线程的创建、使用、生命周期、状态以及线程同步和死锁的概念和处理方法。
Java-Thread多线程的使用

热门文章

最新文章

下一篇
无影云桌面