《Java并发编程实战》学习笔记--线程安全性以及对象的共享

并发编程简介

上古时期的计算机没有操作系统,它们从头到尾只运行一个程序。这个程序独占计算机上所有的资源。只有当一个程序运行完之后才继续运行其他的程序。这对于当时昂贵的计算机资源来说是一种很大的浪费。随着操作系统的出现,使得计算机每次能够运行多个程序。并且不同的程序都在独立的进程当中运行:操作系统为各个独立执行的进程分配各种资源,包括内存,文件句柄等等。如果需要的话,在不同的进程之间可以通过一些粗粒度的通信机制来交换数据。之所以在计算机中加入操作系统来实现多个程序的同时执行,主要是基于以下原因:

  • 资源利用率:在某些情况下,程序必须等待某个外部操作完成之后才能继续执行,比如说I/O操作。然而在等待I/O操作完成的过程中,程序无法执行其他操作,此时该程序会把CPU让出来。此时如果在等待I/O操作完成的同时运行其他程序,那将提高计算机资源的利用率。
  • 公平性:不同的用户和程序对于计算机上的资源有着同等的使用权。一种高效的运行方式是通过粗粒度的时间分片(Time Slicing)使这些用户和程序能共享计算机资源,而不是由一个程序从头到尾运行,然后再启动下一个程序。
  • 便利性:一般来说在计算多个任务时,应该编写多个程序,每个程序执行一个任务并在必要的时候互相通信,这比只编写一个程序来计算所有任务更容易实现。

在早期的分时系统中,每个进程都相当于一台冯诺依曼计算机,它拥有存储指令和数据的内存空间,根据机器语言的语义以串行方式执行指令,并通过一组I/O指令与外部设备通信。对于每条被执行的指令来说,它们都有相应的“下一条指令”,程序中的控制流是按照指令集的规则来确定的。当前,几乎所有的主流编程语言都遵循这种串行编程模型,并且在这些语言的规范中也都清晰地定义了在某个动作完成之后需要执行的“下一个动作”;串行编程模型的优势在于其直观性和简单性,因为它模范了人类的工作方式:每次只做一件事情,做完之后再做下一件。就拿泡茶为例:我们在泡茶的时候,通常是将茶叶从柜子里拿出来放到杯子里,然后去烧水,等水开了之后再将开水倒入杯中。我们也可以先把水放到壶子里面去烧,然后再等待水开的过程中放好茶叶,甚至可以去干其他的事情,直到水烧开了再来泡茶。因为在这个过程中存在一定的异步性。因此,如果我们想变成一个做事很有效率的人,就必须在串行性和异步性之间找到合理的平衡,对于程序来说同样如此。

线程安全性问题

这些促使进程出线的因素(资源利用率、公平性以及便利性等)同样和促使着线程的出现。线程允许在同一个进程中同时存在多个程序控制流。线程会共享进程范围内的资源。线程还提供了一种直观的分解模式来充分利用多处理器系统中的硬件并行性,而在同一个程序中的多个线程也可以被同时调度到多个CPU上运行。线程也被称为轻量级进程。在大多数现代操作系统中,都是以线程为基本的调度单位,而不是进程。如果没有明确的同步机制,那么线程将彼此独立执行。由于同一个进程中的所有线程都将共享进程的内存地址空间,因此这些线程都能访问相同的变量并在同一个堆上分配对象,这就需要实现一种比在进程间共享数据粒度更细的数据共享机制。如果没有明确的同步机制来协同对共享数据的访问,那么当一个线程正在使用某个变量时,另一个线程可能同时访问这个变量,这将导致不可预测的结果。下面将用一个非线程安全的数值序列生成器进行举例:

public class UnsafeSequence{ private int value; public int getNext(){ return value++; } } 程序1-1
上面这段代码的问题在于,如果执行时机不对,那么两个线程在调用getNext时会得到相同的值。虽然看上去递增运算是单个操作,但事实上它包含三个独立的操作:读取value,将value加1,并将计算结果写入value.由于运行时可能将多个线程之间的操作交替执行,因此这两个线程可能同时执行读操作,从而使得它们得到相同的值,并都将这个值加1.结果就是,在不同线程的调用中返回了相同的数值。

UnsafeSequence.getNext()的错误执行情况

上面是一种常见的并发安全问题,称为竞态条件(Race Condition)。在多线程的环境下,getNext是否会返回唯一的值,要取决于运行时对线程中操作的交替执行方式。其实,我们可以将getNext修改为一个同步的方法,就能避免出现上面的问题:

public class SafeSequence{ private int value; public synchronized int getNext(){ return value++; } } 程序1-2

那么,我们说了那么多,线程安全性到底是什么?
还有,为什么在方法上加上一个synchronized就能防止程序1-1中错误的交替执行情况呢?

下面,我将对上面这两个问题进行解答

首先我们来说说线程安全性到底是什么。在给线程安全性下一个定义之前,我们先来弄明白什么叫做对象的状态。从非正式意义上来说,对象的状态是指存储在状态变量(例如实例或者静态域)中的数据。对象的状态可能包括其他依赖对象的域。例如,某个HashMap的状态不仅存储在HashMap对象本身,还存储在许多Map.Entry对象中。在对象的状态中包含了人和可能影响其外部可见行为的数据。

“共享”意味着变量可以由多个线程同时访问,而“可变”则意味着变量的值在其生命周期内可以发生变化。一个对象是否需要是线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问,如果无法实现协同,那么可能会导致数据破坏以及其他不该出现的结果。

关于线程安全性
其实要对线程安全性给出一个明确的定义是非常复杂的。比如说我们在网络上搜索时,能搜到许多关于线程安全的定义:

可以在多个线程中调用,并且在线程之间不会出现错误的交互;
可以同时被多个线程调用,而调用者无须执行额外的动作。

其实上面这两句话听起来就像“如果某个类可以在多个线程中安全地使用,那么它就是一个线程安全的类”。
在线程安全性的定义中,最核心的概念就是正确性。如果对线程安全性的定义是模糊的,那么就是因为缺乏对正确性的清晰定义。

正确性的含义是,某个类的行为与其规范完全一致。相应的,我们可以给线程安全性下一个定义:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

Synchronized协助实现线程安全

Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包括两个部分:一个作为锁的对象引用,一个作为有这个锁保护的代码块。每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出,获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。

其实,Java的内置锁相当于一种互斥锁。即在同一时刻,只有一个线程能够持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或阻塞,直到线程B释放这个锁。如果B永远不释放锁,那么A也将永远等待下去。
其实这也就解释了为什么在程序1-1的方法上加一个Synchronized就防止错误的交替执行情况了:
在程序1-1中,getNext将会出现的问题是可能有多个线程在同一时间调用该方法,有可能获得的value值会相同。然而如果在方法上加上Synchronized,情况就截然相反了:当一个方法被Synchronized修饰,那就说明在同一时刻只能由一个线程访问该方法。因此,由这个锁保护的同步代码块,在这里即方法体会以原子方式执行,多个线程在执行该代码块时也不会互相干扰。

此时,我们已经知道了同步代码块和同步方法可以确保以原子的方式执行操作,但是有一种常见的误解就是认为关键字Synchronized只能用于实现原子性或者确定“临界区(Critical Section)”。同步还有另一个重要的方面:内存可见性(Memory Visibility)。我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且希望确保当一个线程修改了对象状态之后,其他线程能够看到发生的状态变化。

可见性

其实可见性是一种复杂的属性,因为常常可见性的错误总是会违背我们的直觉。在单线程的环境中,如果向某个值先写入值,然后在没有其他写入操作的情况下读取这个变量,那么我们总能获得相同的值。但是当读操作和写操作在不同的线程中执行的时候,情况却并非如此:因为我们无法确保执行读操作的线程能够在适当的时间看到其他线程写入的值。所以为了确保多个线程之间对内存的写入操作的可见性,我们就必须使用同步机制。
下面将给出一个错误的共享变量的程序作为例子:
public class NoVisibility{ private static boolean ready; private static int number; private static class ReaderThread extends Thread{ public void run(){ while(!ready){ Thread.yield(); System.out.println(number); } } public static void main(String[] args){ new ReaderThread().start(); number = 42; ready = true; } } } 程序1-3
在程序1-3中,主线程和读线程都将访问共享变量ready和number。理想的情况是:主线程启动读线程,然后主线程将number设为42,并且将ready设为true。读线程一直循环知道发现ready变为了true,然后再输出number的值。似乎看起来是这样的。可是由于缺少同步机制,读线程可能看不到主线程对这个两个共享变量的值进行了更改,所以情况可能变得非常糟糕。可能由于读线程没有发现ready已经被设为了true,程序可能一直循环下去。或者是读线程发现了ready已经被设为了true,但是输出的number的值却为0(这种现象被称为重排序)。所以当有多个线程访问共享变量时,我们就必须使用合适的同步机制来避免得出错误的结论:

在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论。因此,只要有数据在多个线程之间共享,就必须使用正确的同步。

失效数据

下面再来看看一个程序:
public class MutableInteger{ private int value; public int get() { return value;} public void set(int value){ this.value = value;} } 程序1-4
在缺乏足够同步的情况下,我们还可能遇到另一种错误的结果:失效数据。就如程序1-4所示,如果某个线程调用了set方法,那么另一个正在调用get方法的线程可能会看到更新之后的value值,也可能看不到。所以我们要添加适当的同步机制,使得程序1-4变成一个线程安全的类:
public class SynchronizedInteger{ private int value; public int get() { return value;} public void set(int value){ this.value = value;} } 程序1-5
其实很简单,只要在set和get方法上加上关键字Synchronized即可。仅仅对set方法设置同步是不够的,因为调用get的线程仍然会看见失效值。

非原子的64位操作

当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机的值。这种安全性保证也被称为最低安全性(out-of-thin-air safety)

最低安全性适用于绝大多数变量,但是有一个例外:非volatile类型的64位数值变量(double 和 long)。Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作和写操作分解为两个32位的操作。当读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作不在同一个线程内进行,那么很可能会读取到某个值的高32位和另一个值得低32位。因此即使不考虑失效数据的问题,如果需要在多线程的环境下使用共享并且是可变的long和double等类型的变量也是不安全的,必须用关键字volatile来声明它们,或者是用锁保护起来。

既然说到了volatile这个关键字,下面我们就来了解一下volatile变量

volatile变量

Java语言还提供了一种比Synchronized锁稍弱的同步机制,即volatile变量,可以用来确保将变量的更新操作通知到其他的线程。当把变量声明为volatile之后,编译器与运行时(Runtime)都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量也不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

其实,volatile变量对可见性的影响比le变量本身更为重要。当线程A首先写入一个volatile变量并且线程B随后读取这个volatile变量时,在写入volatile变量之前对A可见的所有变量的值在B都去了volatile变量后,对B也是可见的。因此从内存可见性的角度来看,写入volatile相当于退出同步代码块,读取volatile变量相当于进入同步代码块。但是并不建议过度依赖volatile变量提供的可见性。如果在代码中依赖volatile变量来控制状态的可见性,通常比使用锁的代码更为脆弱,也难以理解。

虽然volatile变量很方便,但是也存在一些局限性。volatile变量通常用做某个操作完成、发生中断或者状态的标志。尽管volatile变量也可以标识其他的状态信息,但是在使用的时候要非常小心。例如,volatile的语义不足以确保递增操作(count++)的原子性,除非能够确保只有一个线程对变量进行写操作。(原子变量提供了“读-改-写”的原子操作,并且常常用做一种“更好的volatile变量”)

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

仅当满足一下所有条件时,才应该使用volatile变量:

  • 对变量的写入操作不依赖变量的当前值,或者能够确保只有单个线程更新变量的值。
  • 该变量不会与其他状态变量一起纳入不变性条件中。
  • 在访问变量时不需要加锁。
发布与逸出
  • 发布:使对象能够在当前作用域之外的代码中使用。比如说,将一个指向该对象的引用保存到其他类可以访问的地方,或者是在某一个非私有的方法中返回该引用,或者是将引用传递到其他类的方法中。
  • 逸出:当某个不应该被发布的对象被发布时就称为逸出。

当我们在编写程序的时候,要特别注意不要使内部的可变状态逸出,或者是将我们程序中一些private修饰的变量逸出了。看看下面这个例子:

class UnsafeStates{ private String[] states = new String[] {"AK","AL",......}; public String[] getStates(){ return states; } } 代码1-6
按照代码1-6所示,我们在不经意之间就将数组states逸出了它本来的作用域。如果我们在实际工作当中编写了这样的代码,就有可能会造成严重的后果。除了上面这种显式地逸出,还会出现下面这种隐式地使this引用逸出:
public class ThisEscape{ public ThisEscape (EventSource source){ source.registerListtener( new EventListener(){ public void onEvent(Event e){ doSomething(e); } } ); } } 程序1-7
当ThisEscape发布EventListener时,其实也隐含地发布了ThisEscape实例本身,因为在这个内部类的实例中包含了对ThisEscape实例的隐含引用。

安全的对象构造过程

在构造的过程中使this引用逸出的一个常见错误是在构造函数中启动一个线程。当对象在构造函数中启动一个线程的时候,无论是显式地创建(通过将它传给构造函数)还是隐式创建(由于Thread或Runnable是该对象的一个内部类),this引用都会被新创建的线程共享。在对象尚未完全被构造之前,新的线程就可以看见它,这是非常错误的做法。其实我们可以使用工厂方法来防止this引用在构造的过程中逸出:将构造函数声明为private类型的,即使用一个私有的构造函数以及一个公共的工厂方法,从而避免不正确的构造过程。

线程封闭(Thread Confinement)

什么是线程封闭呢?
当我们访问可变的数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,那我们就不需要同步。这种技术被称为线程封闭。通俗一点说,就是把对象等全部封装在一个线程里面,只有这个线程才能看到它里面有什么东西,这就叫线程封闭。

线程封闭的三种方式(挖个坑,以后再填)

  • Ad-hoc线程封闭
  • 栈封闭
  • ThreadLocal类
不变性

满足同步需求的另一种方法就是使用不可变对象(Immutable Object)。如果某个对象在被创建完之后,它的状态不再发生改变或者是不能发生改变,那我们就称这个对象为不可变对象。线程安全性是不可变对象的固有属性之一,它们的不变性条件是由构造函数创建的,只要它们的状态不改变,那么这些不变性条件就能得以维持。
不可变对象一定是线程安全的
我们在学习Java基础知识的时候都知道final这个关键字,知道经final修饰的变量或者函数是不可变的。然而,我们需要注意的是,虽然在Java语言规范以及Java内存模型中都没有给出不可变性的正式定义,但不可变性并不等于将对象中所有的域都声明为final类型,即使对象中所有的域都是final的,这个对象也仍然可变,因为在final对象中可以保存对可变对象的引用。

当满足以下条件时,对象才是不可变的:

  • 对象创建之后其状态就不能发生改变
  • 对象所有的域都是final类型
  • 对象是正确创建的
安全发布的模式
  1. 在静态初始化函数中初始化一个对象引用;
  1. 将对象的引用保存到volatile类型的域或者AtomicReference对象中;
  2. 将对象的引用保存到某个正确构造对象的final类型域中;
  3. 将对象的引用保存到一个由锁保护的域中.

推荐阅读更多精彩内容

  • 从三月份找实习到现在,面了一些公司,挂了不少,但最终还是拿到小米、百度、阿里、京东、新浪、CVTE、乐视家的研发岗...
    时芥蓝阅读 41,252评论 11 349
  • 第三章 Java内存模型 3.1 Java内存模型的基础 通信在共享内存的模型里,通过写-读内存中的公共状态进行隐...
    泽毛阅读 4,048评论 2 22
  • layout: posttitle: 《Java并发编程的艺术》笔记categories: Javaexcerpt...
    xiaogmail阅读 5,488评论 1 19
  • 这篇博客在我的CSDN上写了很久了,但一直没什么阅读量,就在昨天我的一个同事在google上搜到了这篇文章,觉得挺...
    dongjunkun阅读 4,422评论 21 25
  • 这次的出行让我感触最深的是孩子的成长。第一天晚上只睡了三个来小时还坚持陪我工作,每天那么累都不跟我抱怨。自己背自己...
    大鱼海棠brave阅读 105评论 0 0
  • 多想变成风 去到母亲工作的地方 一滴一滴 为她拭去那沉重的汗滴 多想变成风 去到外婆居住的小屋 一次一次 将她拥抱...
    烟雨心清阅读 187评论 2 5
  • UITableView继承自UIScrollView 1. UITableView是一个表格控件 2. 使用UIT...
    Brice_Zhao阅读 249评论 0 1
  • 离別之后才懂了它——这句话中包含着一份检讨。我们一直偎依它、吮吸它,却又埋怨它、轻视它、责斥它。它花了几千年的目光...
    宝丁阅读 460评论 0 2