Java并发编程实战 Java内存模型总结

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

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

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

如果在程序中没有包含足够的同步 那么可能产生奇怪的结果(不要这么做)

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 + ")");
    }
}

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

Java内存模型简介
Java内存模型是通过各种操作来定义的 包括对变量的读/写操作 监视器的加锁和释放操作 以及线程的启动和合并操作 JMM为程序中所有的操作定义了一个偏序关系 称之为Happens-Before 要想保证执行操作B的线程看到操作A的结果(无论A和B是否在同一个线程中执行) 那么在A和B之间必须满足Happens-Before关系 如果两个操作之间缺乏Happens-Before关系 那么JVM可以对它们任意地重排序

Happens-Before的规则包括:

  • 程序顺序规则 如果程序中操作A在操作B之前 那么在线程中A操作将在B操作之前执行
  • 监视器锁规则 在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前执行
  • volatile变量规则 对volatile变量的写入操作必须在对该变量的读操作之前执行
  • 线程启动规则 在线程上对Thread.Start的调用必须在该线程中执行任何操作之前执行
  • 线程结束规则 线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行 或者从Thread.join中成功返回 或者在调用Thread.isAlive时返回false
  • 中断规则 当一个线程在另一个线程上调用interrupt时 必须在被中断线程检测到interrupt调用之前执行(通过抛出InterruptedException 或者调用isInterrupted和interrupted)
  • 终结器规则 对象的构造函数必须在启动该对象的终结器之前执行完成
  • 传递性 如果操作A在操作B之前执行 并且操作B在操作C之前执行 那么操作A必须在操作C之前执行

借助同步
由于Happens-Before的排序功能很强大 因此有时候可以 借助(Piggyback) 现有同步机制的可见性属性 这需要将Happens-Before的程序顺序规则与其他某个顺序规则(通常是监视器锁规则或者volatile变量规则)结合起来 从而对某个未被锁保护的变量的访问操作进行排序 这项技术由于对语句的顺序非常敏感 因此很容易出错 它是一项高级技术 并且只有当需要最大限度地提升某些类(例如ReentrantLock)的性能时 才应该使用这项技术
在FutureTask的保护方法AbstractQueuedSynchronizer中说明了如何使用这种 借助 技术 AQS维护了一个表示同步器状态的整数 FutureTask用这个整数来保存任务的状态:正在运行 已完成和已取消 但FutureTask还维护了其他一些变量 例如计算的结果 当一个线程调用set来保存结果并且另一个线程调用get来获取该结果时 这两个线程最好按照Happens-Before进行排序 这可以通过将执行结果的引用声明为volatile类型来实现 但利用现有的同步机制可以更容易地实现相同的功能

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

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

发布
造成不正确发布的真正原因 就是在 发布一个共享对象 与 另一个线程访问该对象 之间缺少一种Happens-Before排序

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

不安全的延迟初始化(不要这么做)

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

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

安全的发布
安全发布常用模式可以确保被发布对象对于其他线程是可见的 因为它们保证发布对象的操作将在使用对象的线程开始使用该对象的引用之前执行 如果线程A将X放入BlockingQueue(并且随后没有线程修改它) 线程B从队列中获取X 那么可以确保B看到的X与A放入的X相同 这是因为在BlockingQueue的实现中有足够的内部同步确保了put方法在take方法之前执行 同样 通过使用一个由锁保护共享变量或者使用共享的volatile类型变量 也可以确保对该变量的读取操作和写入操作按照Happens-Before关系来排序

安全初始化模式

线程安全的延迟初始化

@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;
    }

    static class Resource {
    }
}

延迟初始化占位类模式

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

双重检查加锁

双重检查加锁(不要这么做)

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

    }
}

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

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

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

不可变对象的初始化安全性

@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);
    }
}

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

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值