带你读懂《Java并发编程》:第3章 助于线程安全的三剑客:final&valitale&线程封闭

依然范特西╮ 提交于 2020-12-14 22:23:25




点击上方蓝字关注我们








我们简要的 回顾前文:
《第1章 多线程安全性与风险》介绍了并发编程, 在维护难度、性能以及活跃性三个方面,所带来的风险与优势
《第2章 影响线程安全性的原子性和加锁机制》介绍了并发编程核心概念 -- 线程安全性, 介绍了用同步的手段,来避免多个线程在同一时刻访问相同的数据
今天分享的是《Java并发编程实战》第3章 -- “对象的共享”: 介绍用安全共享和发布对象的手段,来让多个线程能够安全的同事访问同一数据





开始前,我们先回顾下几个重要的基础知识:缓存一致性 和 Java内存模型
现实计算机系统是存在缓存一致性问题的,见下图:


  • 现实因素:由于计算机存储设备与处理器的运算速度有个数量级的差距(即便存储设备使用SSD,还是比处理器慢几个数量级),所以计算机系统不得不增加一层读写速度尽可能接近处理器速度的高速缓存Cache来作为内存和处理器之间的缓冲。


  • 缓存一致性(Cache Coherence):基于高速缓存的存储交互解决了处理器和内存的速度矛盾,但是引入了缓存一致性(在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一个主内存(Main Memory),当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致)。


  • Java内存模型(Java Memory Model,JMM):基于现实因素制约造成的内存并发不一致,JVM 定义了一套 JMM 来屏蔽掉各种硬件和操作系统的内存访问差异,以达到统一共和,哦不,是让 Java 程序一处编译到处运行,实现在各种平台下都能达到一致的内存访问效果。


    1)JMM:规定所有变量都存在主内存(Main Memory)了,每条线程还有自己的工作内存(Working Memory),工作内存保存了变量的主内存拷贝



2)JMM 内 存模型里,从Java线程 -> 工作内存 -> 主内存之间的1次读写分为8个基本操作:(篇幅所限不展开,后续文章会跟进讲解)

操作
作用
lock (锁定)
作用于主内存的变量,把一个变量标识为线程独占状态
unlock (解锁)

作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定

read (读取)
作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
load (载入)

作用于工作内存的变量,它把read操作从主存中变量放入工作内存中

use (使用)
作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令
assign (赋值)
作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
store (存储)
作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用
write (写入)
作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

3)JMM的内存间交互操作时序见下图:




对象的可见性




我们通过下面的例子来认识下潜在的变量可见性问题。


例子1:变量的可见性不暴露于多线程

/*** <p>可见性测试</p> */public class NoVisibility {//非线程安全的状态变量private static boolean ready;private static int number;
private static class ReaderThread extends Thread{@Overridepublic void run() {while (!ready)//挂起,让出CPU占用权 Thread.yield(); System.out.println("ready = " + ready + " , and number = " + number); } }
public static void main(String[] args) throws InterruptedException {new ReaderThread().start(); number=43; ready=true; }}


例子1 输出结果:大部分情况下,我们运行程序可以得到下面的输出:


ready = true , and number = 43


例子1 代码解析:

主线程A开启了一个异步线程B(B负责循环校验 ready变量,如果为false,继续让出CPU所有权空轮转,如果为true,打印日志)。这个程序的大部分情况运行结果都正确,但它并非线程安全。


  • 上面的 NoVisibility 类可能会无限循环下去,因为 ready 值可能对Reader线程来说一直是不可见的。
  • 在更为特殊的情况下,NoVisibility甚至可能会打印出0,因为可能会在写入number之前写入ready值,这是由于著名的“指令重排序”(reordering)现象。


Q1:指令重排序是什么?

指令是指示计算机执行某种操作的命令,如:数据传送指令、算术运算指令、位运算指令、程序流程控制指令、串操作指令、处理器控制指令。


Java语言规范JVM线程内部维持顺序化语义:

只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码逻辑顺序不一致,这个过程就叫做指令的重排序。    


指令重排序的意义:使指令更加符合CPU的执行特性,最大限度的发挥机器的性能,提高程序的执行效率。



代码优化:只要数据在多个线程之间共享,就使用正确的同步。




维护可见性的两种手段


维护可见性也有两种手段:一是同步锁,二是使用volatile关键字。

维护可见性之一:同步锁使用。

例子1 的NoVisiability 展示了缺乏同步的程序可能产生的错误情况:失效数据。失效数据可能不会同时出现,一个线程可能获取新值,而另一个获取旧值。我们看下例子2和例子3:

反例2:线程不安全的例子

@NotThreadSafepublic class MutablInteger {private int value;
public int getValue() {return this.value; }
public void setValue(final int value) {this.value = value; }}


正例3:使用同步锁优化后的例子

@ThreadSafepublic class SynchronizedInteger {private int value;
public synchronized int getValue() {return this.value; }
public synchronized void setValue(final int value) {this.value = value; }}


例子2、3的代码解析

  • 我们通过对getter/setter方法同时加锁,确保所有线程都在拥有同步的情况下访问value。

加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。


维护可见性之二:volatile 变量。


例子2和例子3,其实在没有同步的情况下,可能会得到一个失效值,但至少是由之前的线程设置的,不是随机值,此称为“最低安全性”(out-of-thin-airsafety)。可是,最低安全性适用于绝大多数变量,但不适用于非volatile类型的64位数值变量。因此我们需要使用 volatile 来声明它们或者用锁保护起来。


JMM 要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。 


当读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位。


Volatile变量

Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保变量的更新操作通知到其他线程。


  • 禁止重排序:当把变量声明为volatile类型后,编译器与运行时都会注意到这个 变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。
  • 读取写入作用于主内存:volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。


例子4:volatile 的使用


public class CountSheeps {  static volatile boolean asleep;  public static void main(String[] args) throws InterruptedException {    new CountSheepsThread().start();    Thread.sleep(10000);    asleep = true;  }  private static class CountSheepsThread extends Thread{    @Override    public void run() {      while (!asleep)        Thread.yield();      countSheep();    }    private void countSheep() {      System.out.println("zzz...");    }  }}


例子4分析:

在例子4中,主线程A在休眠10s后,将 asleep 变量的值修改为true,在A休眠的10s里,线程B一直处于轮训中的可执行状态,直到asleep的值被修改。换句话说,asleep 是线程A和B的共享变量,利用volatile修饰之后,A线程的改动会及时通知B线程,进而B线程执行后续业务。


给volatile做个总结:

加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。

《Java并发编程》建议当且仅当满足以下所有条件时(相当苛刻的条件),才应该使用volatile变量:

  • 变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值
  • 该变量不会与其他状态变量一起纳入不变性条件中。 
  • 访问变量时不需要加锁


对象的发布/逸出



对象的“发布(Publish)”是指:一个对象能够在当前作用域之外的代码中使用。对象的“逸出(Escape)”是指:当某个不应该发布的对象被发布时,这种情况就被称为。常见的发布对象的手段有两种:

  • 第一种发布对象方式是将对象那个的引用保存到一个公有的静态变量中,以便任何类和线程都能看见该对象。


例子5:不安全的对象发布
@NotThreadSafepublic class PublishObject2 {  private String[] states = new String[] {"AK","AL"};  public String[] getStates() { return states; }}

例子5 解析
由于states逸出了它所在的作用域,因为这个本应是私有的变量已经被发布了,导致任何调用者都可以修改这个数组的内容。

  • 第一种发布对象方式是:发布一个内部的类实例。


例子6:也是不安全的对象发布。


/** * <p>内部类,this在构造函数中逸出</p> */@NotThreadSafepublic class ThisEscape {  public static void main(String[] args) {    EventSource eventSource = new EventSource();    new ThisEscape(eventSource);  }  // ThisEscape 对象的构造器  public ThisEscape(EventSource source) {    source.registerListener(        new EventListener() {          //发布匿名对象(也发布了ThisEscape实例)          public void onEvent(Event e) {            doSomething(e);          }        },this);  }  private void doSomething(Event e) {  }}

public class EventSource {  public void registerListener(EventListener eventListener, ThisEscape name) {    System.out.println("EventSource知道了这个类,也会将它告诉 EventListener");    eventListener.esclape(name);  }}public class EventListener {  public void onEvent(Event e) {  }  public void esclape(ThisEscape name) {    System.out.println("这是不安全的对象发布,因为EventSource的告知, EventListener 也知道 ThisEscape 被构建了:" + name.getClass());  }}

例子6 输出结果:
EventSource知道了这个类,也会将它告诉 EventListener这是不安全的对象发布(可以在一个对象的构造器里做点别的事情,该对象未完全构造完成,就可以被别的地方调用,因此属于逸出现象),因为EventSource的告知, EventListener 也知道 ThisEscape 被构建了:class shizhanthread.chapter3.ThisEscape

例子6 解析
由于在构造器中进行内部类的创建,导致该构造器的对象同样被“无意中发布了”。
隐式或者显示的使用this引用,会导致构造器拥有者逸出,因此,不要在构造过程中使this引用。



线程封闭






上面提到的对象发布和逸出,是为了合理处理对象作用域的范围,说到底是为了不让它被其它线程随意修改数据和状态。那有没有断绝其他线程干扰的方法呢?

有的,避免使用同步的方式就是不共享数据,即:线程封闭(Thread Confinement)。

线程封闭(Thread Confinement)技术:只在单线程内访问数据,不需要同步,它是实现线程安全性的最简单方式之一。

线程封闭的实现手段有三种:Ad-hoc线程封闭、栈封闭 和 ThreadLocal 类。


Ad-hoc 线程封闭

Ad-hoc线程封闭,用一句大白话说:维护线程封闭性的职责完全由程序实现来承担(你的线程封闭业务逻辑,如果你用Java来写,就用Java的语法来维护,并且逻辑和真实运行结果都要由你自个保证)。

也就是说,这是完全靠实现者控制的线程封闭,因此,也导致Ad-hoc线程封闭非常脆弱,没有任何一种语言特性能将对象封闭到目标线程上。

维基百科上对于Ad hoc这个词有专门的解释“Ad hoc是拉丁文常用短语中的一个短语。这个短语的意思是'特设的、特定目的的、即席的、临时的、将就的、专案的'。

这个短语通常用来形容一些特殊的、不能用于其它方面的的,为一个特定的问题、任务而专门设定的解决方案。这个词汇须与a priori区分。


栈封闭

栈封闭,用一句大白话说,就是局部变量

多个线程访问一个方法,此方法中的局部变量都会被拷贝一分儿到线程栈中。所以局部变量是不被多个线程所共享的,也就不会出现并发问题(有疑惑的同学,可以回顾开篇提及的 Java内存模型)。所以能用局部变量就别用全局的变量,全局变量容易引起并发问题。


ThreadLocal类

ThreadLocal,用一句大白话说,它是实现线程封闭的最好方法。ThreadLocal内部维护了一个Map,Map的key是每个线程的名称,而Map的值就是我们要封闭的对象。每个线程中的对象都对应着Map中一个值,也就是ThreadLocal利用Map实现了对象的线程封闭。(篇幅所限,后续源码分析再做展开)

ThreadLocal 类提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。




对象不变性和Final域






对象的不可变性(Immutable Object)是通过 Final域声明,来实现不可变性对象的构造。而被 Final 关键字修饰类型的域是不能修改的,Final 实际上可以用于修饰类/方法/数据域:

  • 类:Final 类不可被继承,无子类,保证用户调用时动作的一致性,可以防止子类覆盖情况的发生。

  • 方法:方法不可被覆盖,private 修饰的方法默认带上了final。

  • 数据域:变量的值为常量,常量的地址不可改变,但在地址指向的内存空间保存的值(即对象的属性)是可以改变的。【本章讲的主要是数据域这个情况


例子7:在不可变对象中可以使用可变对象管理他们的状态


@Immutablepublic final class ThreeStooges {  //不可变对象的引用,但是Set的数据可以被更新  private final Set<String> stooges = new HashSet<String>();  public ThreeStooges() {    stooges.add("Moe");    stooges.add("Larry");    stooges.add("Curly");  }  //这是读操作,允许并发读(除非还有并发写的操作,否则就是不安全的)  public boolean isStooge(String name) {    return stooges.contains(name);  }}


例子7:代码分析

尽管stooges 是保管姓名的Set对象,它是可变的。但从设计来看,在Set对象构造完成后,没有对外暴露任何修改的API,所有的对象状态都通过一个final 域来访问,因此实现了对象不变性。

不可变对象一定是线程安全的。





总结




我们在实战领域编写并发程序,在使用或者共享对象时,《Java并发实战》给我们总结了4条规则:

  • 线程封闭

  • 只读共享。共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象

  • 线程安全共享。线程安全地对象在器内部实现同步。

  • 保护对象。被保护的对象只能通过持有特定的锁来方访问。




推荐阅读



《源码系列》

JDK之Object 类

JDK之BigDecimal 类

JDK之String 类


《经典书籍》

Java并发编程实战:第1章 多线程安全性与风险

Java并发编程实战》:第2章 影响线程安全性的原子性和加锁机制

Effective Java 第53条:接口优先于反射机制

Effective Java 第52条:通过接口引用对象

Effective Java 第54条:谨慎的使用本地方法


《服务端技术栈》

Docker 核心设计理念

Kafka史上最强原理总结

HTTP的前世今生


《算法系列》

读懂排序算法(二):希尔排序算法

读懂排序算法(三):堆排序算法

读懂排序算法(四):归并算法

读懂排序算法(五):快速排序算法






扫描二维码

获取技术干货

后台技术汇




点个“在看”表示朕

已阅



本文分享自微信公众号 - 后台技术汇(gh_bbd0c11cb61f)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!