Java并发编程实战(学习笔记十五 第十六章 Java内存模型(JMM))

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

假设一个线程为变量aVariable赋值:
aVariable=3;
内存模型需要解决这个问题:”在什么条件下,读取aVariable的线程将看到这个值为3”。如果缺少同步,将有许多因素使得线程无法立即甚至永远,看到另一个线程的操作结果。

JMM规定了JVM必须遵循一组最小保证,这组保证规定了对变量的写入操作在何时将对于其他线程可见。JMM在设计时就在可预测性和程序的易于开发行之间进行了权衡,从而在各种主流的处理器体系架构上能实现高性能的JVM。

16.1.1 平台的内存模型

在共享内存的多处理器体系架构中,每个处理器都拥有自己的缓存,并且定期与主内存进行协调。在不同的处理器架构中提供了不同级别的缓存一致性(Cache Coherence),其中一部分只提供最小的保证,即允许不同的处理器在任意时刻从同一个存储位置上看到不同的值。

在架构定义的内存模型中将告诉应用程序可以从内存系统获得怎样的保证,此外还定义了一些特殊的指令(称为内存栅栏或栅栏),当需要共享数据时,这些指令就能实现额外的存储协调保证。
Java提供了自己的内存模型,并且JVM在适当的位置上插入内存栅栏来屏蔽在JMM与底层平台内存模型之间的差异、

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

16-1说明了,在没有正确同步的情况下,即使要推断最简单的并发程序的行为也是困难的。

//  16-1 如果没有包含足够的同步,可能产生奇怪的结果(不要这么做)
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(); //直到线程t完成,此线程再继续;通常用于在main()主线程内,等待其它线程完成再结束main()主线程
        other.join();
        System.out.println("( " + x + "," + y + ")");
    }
}

容易想象出是如何输出(0,1),(1,0)或者(1,1)的:线程A可以在线程B开始之前就执行完成,B也可在A开始之前就完成,或者两者的操作交替运行。
奇怪的是,还可以输出(0,0)。
由于每个线程各个操作之间不存在数据流依赖性,因此这些操作可以乱序执行。
下图给出了一种可能由重排序导致的交替执行方式,这种情况下输出(0,0)这里写图片描述

如果没有同步,推断出执行顺序是非常困难的,而要确保在程序中正确使用同步却很容易。同步将限制编译器,运行时和硬件对内存操作重排序的方式,从而在实施重排序时不会破坏JMM提供的可见性保证。

16.1.3 Java内存模型简介

JMM是通过各种操作来定义的,包括对变量的读/写操作,监视器的加锁和释放操作,以及线程的启动和合并操作。JMM为程序中所有的操作定义了一个偏序关系,称之为Happens-Before。
要想保证执行操作B的线程看到操作A的结果,那么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之前执行

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

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

16.1.4 借助同步

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

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

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

//        16-2  说明如何借助同步的FutureTask的内部类
public class FutureTask<V> {
    private final class Sync extends AbstractQueuedSynchronizer {
        private static final int RUNNING = 1, RAN = 2, CANCELLED = 4;
        private V result;
        private Exception exception;
        //由于innerSet将在调用releaseShared(会调用tryReleaseShared)之前写入result,并且innerGet将在调用acquireShared(会调用tryAcquireShared)之后读取result,因此将程序顺序规则与volatile变量规则结合在一起,可确保innerSet的写入操作在innerGet中的读取操作之前执行
        void innerSet(V v) {
           while (true) {
              int s = getState();  //获得当前的同步器状态值
              if (ranOrCancelled(s))
                 return;
              if (compareAndSetState(s, RAN))
                 break;
           }
           result = v;
           releaseShared(0);
           done();
        }
        V innerGet() throws InterruptedException, ExecutionException {
          acquireSharedInterruptibly(0);
          if (getState() == CANCELLED)
            throw new CancellationException();
          if (exception != null)
            throw new ExecutionException(exception);
          return result;
        }
    }
}

之所以称为”借助”,是因为它使用了一种现有的Happens-Before顺序(releaseShared在acquireShared之前执行)来确保对象X的可见性,而不是专门创建一种Happens-Before顺序。

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

16.2 发布

第三章中介绍的各种安全技术,它们的安全性都来自与JMM提供的保证,而造成不正确发布的真正原因,就是在”发布一个共享对象”与”另一个线程访问该对象”之间缺少一种Happens-Before操作

16.2.1 不安全的发布

当缺少Happens-Before关系时,可能出现重排序问题,这就解释了为什么在没有充分同步的情况下发布一个对象会导致另一个线程看到一个只被部分构造的对象(3.5节)。

在初始化一个新的对象时 需要写入多个变量,即新对象中的各个域,同样,在发布一个引用时也需要写入一个变量,即新对象的引用。如果无法确保发布共享引用的操作在另一个线程加载该共享引用之前执行,那么对新对象引用的写入操作与对象中各个域的写入操作重排序。这种情况下,另一个线程可能看到对象引用的最新值,但同时也将看到对象的某些或全部状态中的无效值,即一个被部分构造对象。

错误的延迟初始化将导致不正确的发布,如16-3。
我们可以看到存在竞态条件问题(先检查后执行,在2.2.1),同时还存在另一个线程可能看到对部分构造的Resource实例的引用

//   16-3  不安全的延迟初始化(不要这样做)
@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是第一个调用getInstance的线程。它将看到resource为null,并且初始化一个新的Resource,然后resource设置为执行这个新实例。当线程B随后调用getInstance,它可能拷打resource的值为非空,因此使用这个已经构造好的Resource。但线程A写入resource和线程B读取resource操作之前不存在Happens-Before关系。在发布对象时存在数据竞争问题,因此B不一定能看到Resource的正确状态

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

16.2.2 安全的发布

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

Happens-Before比安全发布提供了更强可见性和顺序保证。如果将X从A安全地发布到B,那么这种安全发布可以保证X状态的可见性,但无法保证A访问的其他变量的状态可见性。然而,如果A将X置入队列的操作在线程B从队列中获取X的操作之前执行,那么B不仅能看到A留下的X状态,还能看到A在移交X之前所做的任何操作。

Happens-Before排序是在内存访问级别上操作的,它是一种”并发级汇编语言”,而安全发布的运行级别更接近程序设计

16.2.3 安全初始化模式

16-4中,通过将getResource方法声明为synchronized,可以修复16-3中的问题.由于getInstance的代码路径很短,因此如果没有被多个线程频繁调用,那么在SafeLazyInitialization不会存在激烈的竞争,从而能提供令人满意的性能

延迟初始化(等到要使用的时候才初始化)

//  16-4  线程安全的延迟初始化
@ThreadSafe
public class SafeLazyInitialization {
  private static Resource resource;
  public synchronized static Resource getInstance() {
    if (resource == null)
      resource = new Resource();
    return resource;
  }
  static class Resource {
  }
}

静态(static修饰)初始化器是由JVM在类的初始化阶段执行,即在类被加载后并且被线程使用之前。由于JVM将在初始化期间获得一个锁,内存写入操作将自动对所有线程可见。因此无论是在被构造期间还是被引用时,静态初始化的对象都不需要显式的同步。但这个规则仅适用于在构造时的状态,如果对象是可变的,那么在读线程和写线程之间仍需要通过同步来确保随后的修改操作是可见的,以及避免数据破坏。

在16-5中,通过使用提前初始化(在要使用之前就初始化),避免了在每次调用SafeLazyInitialization中的getInstance时所产生的同步开销。

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

    public static Resource getResource() {
        return resource;
    }

    static class Resource {
    }
}

通过将这项技术和JVM的延迟加载机制结合起来,可以形成一种延迟初始化技术,从而在常见的代码路径中不需要同步。
16-6中使用了一个专门的类来初始化Resource。JVM将推迟ResourceHolder的初始化操作,直到开始使用这个类时才初始化,并且由于通过一个静态(static)初始化来初始化Resource,因此不需要额外的同步。当任何一个线程第一次调用getResource时,都会使ResourceHolder被加载和被初始化,此时静态初始化器将执行Resource的初始化操作。

//      16-6  延长初始化占位类模式
@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 双重检查加锁

在16-7中展示了双重检查加锁(DCL),这是非常不好的

//    16-7   双重检查加锁(不要这样做)
@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的工作原理是:首先检查是否在没有同步的情况下需要初始化,如果不为空,那么直接使用它。否则,就进行同步并再次检查Resource是否被初始化,从而保证只有一个线程对共享的Resource执行初始化。这就是问题所在,线程可能看到一个仅被部分构造的Resource

DCL的问题在于:当没有同步的情况下读取一个共享对象时,可能看到一个失效值(空值),此时DCL将在持有锁的情况下再次尝试来避免这种风险。然而线程可能看到引用的当前值,但对象的状态值却是失效的,这意味着线程可以看到对象处于无效或错误的状态

16.3 初始化过程中的安全性

如果能确保初始化过程的安全性,那么就可以使得被正确构造的不可变对象在没有同步的情况下也能安全在多个线程之间共享。(Resource是不变的情况下unSafeLazyInitialization也是安全的)

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

对于含有final域的对象,初始化安全性可以防止对对象的初始引用被重排序到构造过程之前。

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

//   16-8  不可变对象的初始化安全性
@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);
    }
}

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

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

小结

JMM说明了某个线程的内存操作在哪些情况下对于其他线程是可见的。其中包括确保这些操作是按照一种Happens-Before的偏序关系进行排序,而这种关系是基于内存操作和同步操作级别来定义的。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值