《Java特种兵》5.2 线程安全
本文是《Java特种兵》的样章,感谢博文视点和作者授权本站发布
接下来的内容都将基于多核处理器,因为单核处理器不会出现将要谈到的可见性问题,不过并不代表单核CPU上多个线程就没有一致性问题,因为CPU有时间片原则,还会有其他的一些问题,例如重排序。
本节的内容比较偏重于理论化,看过的同学应该知道这部分内容并不容易看懂。不过要学习并发编程就必然会涉及这方面的内容,所以我们一定要努力去学习,胖哥会尽量通过自己的理解,用一些相对简单的方式让大家得到一些感性认识,进一步的深入就得靠大家自己多去参看相关的资料了。
5.2.1 并发内存模型概述
前文中提到,为了提升性能CPU会Cache许多数据。但在多核CPU下,每个CPU都有自己独立的Cache,每个CPU都可以修改同一个单元数据,因此它们会有自己的一套“缓存一致性协议”,这个一致性是从CPU的角度认为某些来自主存的数据的读取和修改需要一定的一致性保障。
在JVM运行时,每个线程的私有栈在使用共享数据时,会先将共享数据拷贝到栈顶进行运算,这份数据其实是一个副本,因此也同样存在多个线程修改同一个内存单元的一致性问题。JVM自己完成了一套内存模型(Java Memory Model,JMM)的规范,这套模型将基于不同的平台去做特定的优化,JMM的基本管理策略是满足于一种通用的规范,所以这件事情是比较麻烦的。这种模型在JDK 1.2就有了,直到JDK 1.5(JSR-133)及以后的版本中,JVM的内存管理才开始逐步成熟起来。
要了解并发的问题,就要知道并发的问题到底是什么,下面给出一段简单的代码来做个测试,以便对并发的问题有一个初步的了解。
代码清单5-5 初识线程不安全的代码
[code lang=”java”]
public class NoVisiabilityTest {
private static class ReadThread extends Thread {
private boolean ready;
private int number;
public void run() {
while(!ready) {
number++;
}
System.out.println(ready);
}
public void readyOn() {
this.ready = true;
}
}
public static void main(String []args) throws InterruptedException {
ReadThread readThread = new ReadThread();
readThread.start();
Thread.sleep(200);
readThread.readyOn();
System.out.println(readThread.ready);
}
}
[/code]
这段代码请确保运行在Server模式下,运行的结果可能会与我们所预想的不一样,不过某些JDK本身不支持Server模式,则达不到本例子所想要模拟的结果。
由于服务器端程序绝大部分都是在Server模式下,所以本例也并非故意制造变态环境,因此编写服务器端程序的同学应当关注这样的问题。
按照常理来讲,这段代码会在运行一段时间后自动退出,因为readyOn()方法会将对象的ready参数设置为true,那么while(!ready)会失败,理论上就会跳出循环,结束子线程的运行。但是在Server模式下运行时,这段代码会走入死循环,主线程输出了结果,而子线程一直都不结束。
在同一个对象内部,一个线程将该对象的属性修改后,另一个线程看不到该属性被修改后的结果,或者说未必能马上看到结果,这就是并发模型中的“可见性”问题。因为普通变量的修改并不需要立即写回到主存,而线程读取时也不需要每一次都从主存中去读取,因此就可能导致这样的现象。此时若在循环中增加一个Thread.yield();操作就可以让线程让步CPU,进而很可能到主存中读到最新的ready值,达到退出循环的目的。也可以在循环中加一条System.out语句,或者将ready变量增加一个volatile修饰符也可以达到退出的目的。
我们研究这段代码的目的不仅仅是为了解决一个死循环的问题,而且是要告诉大家代码运行过程中确实存在可见性问题。或许前面提到的几种方法可以达到目的,但我们可能并不是太清楚是怎么解决的,或者说在什么情况下用这种方式来解决。
为了解决这个问题,早期做法都是加悲观锁,随着计算机需求的发展,大牛们对技术内在做了许多研究,他们发现这种方式的开销很大,我们希望有更细粒度的控制方式来提升性能。
从宏观上我们将JVM内存划分为堆和栈两个部分,堆内存就是Java对象所使用的区域,在JMM的定义中通常把这块空间叫作“Main Memroy”(主存)。栈空间内部应当包含局部变量、操作数栈、当前方法的常量池指针、当前方法的返回地址等信息,这块空间在我们看来是最接近CPU运算的,也是每个线程私有的空间,当调用某方法开始时将给该私有栈分配空间,在方法内部再调用方法还会继续使用相应的栈空间,方法返回时回收相应的栈空间(不论是否抛出异常)。这块空间通常叫作“Working Memory”(工作内存)。
如果栈内部所包含的“局部变量”是引用(Reference),则仅仅是引用值在栈中,而且会占用一个引用本身的大小,具体的对象还是在堆当中的,即对象本身的大小与栈空间的使用无关。
工作内存与主存之间会采用read/write的方式进行通信,而当工作内存中的数据需要计算时,它会发生load/store操作,load操作通常是将本地变量推至栈顶,用来给CPU调度运算,而store就是将栈顶的数据写入本地变量。
通过javap命令输出指令列表,从中可以看到各种XXload指令(0x15~0x35),就是将不同类型的信息推至栈顶,指令除了可以标识数据类型外,还可以标识宽度;相应的XXstore指令(0x36~0x56)就是将不同类型的栈顶数据存入本地变量。
局部变量(本地变量)在使用时通常不存在一致性问题,因为它的定义本身就归属于线程运行时,生命周期由相应的代码块决定。所以在一致性问题上,我们关注的是多线程对主存的一些数据读写操作。
关于线程换个角度来理解,如果将JVM当成一个操作系统,则可以将多线程本身理解为多个CPU(理论上多个线程可以并行一起运行),站在抽象层次上看,多核处理缓存一致性就和这些思想有许多相通之处了。
Java想要自己来实现一套一致性协议,需要有一些基础规则,下面列举几个。
(1)JMM中一些普通变量的操作指令
◎ Load操作发生在read之后(两个之间可以有其他的指令)。
◎ 普通变量的修改未必会立即发生Store操作,但发生Store操作,就会发生write操作。
有了这个基本规则后,我们似乎就不太关注write/read了,因为Load发生之前肯定有read,而Store操作之后肯定有write,因此我们将读/写的4个步骤理解为简单的2个步骤。
这个JMM的基本规约似乎与多核处理器上的缓存一致性还没有太大关系,因为在代码运行过程中完全有可能一个线程读、一个线程写,此时就可能出现许多奇怪的现象,比如程序中读到了老的数据,或者很久甚至永远都读不到最新的数据。把这类问题叫作“可见性”问题。
在现实中,我们通常会遇到如下版本管理问题。
例子1:你认为自己从SVN上拿到了最新代码去修改,结果你在拿代码时,别人提交了最新版本,在现实中或许没有多大关系,因为版本管理最终可以一致,但是在计算机中或许不行,因为会导致许多意想不到的后果(例如,可能会导致算错数据)。
例子2:拿到了最新版本的代码,一直在修改,一直没有从SVN更新最新的代码,如果在这个过程中别人提交了代码,代码就不是最新的了。
在现实中想要解决这个问题,就需要大家睁大眼睛随时看着SVN这个公共的区域是否发生改变,包括对每一个文件的修改,或者大家将代码就直接写在SVN上,在同一台服务器上调试代码,显然这十分影响工作效率,而且也不现实。计算机也是这样,在绝大部分情况下,各自的变量没有冲突,那么就无须关注对方是否和自己操作同一个内存单元,这样一来计算机的性能就会很高,只是在某些特殊场景下,我们不得不有选择性地使用另一种方式来保障一致性。
这个时候喜欢思考的小伙伴们有了美妙的联想,希望每次使用某个文件前都自动从SVN上提取最新的,如果有人修改了文件,则自动立即上传,上传中如果有人正在下载,则必须等待上传者完成。当然这只是一种假设,在计算机中或许能达到这个粒度,我们就有机会进一步做事情了。
这种最细的粒度支持,也就是对Load、Store的各种顺序控制,load、store两两组合为4种情况:LoadLoad、StoreStore、LoadStore、StoreLoad,它们以一种指令屏障的方式来控制顺序,有些系统可能不支持某些指令的顺序化,不过绝大部分系统都支持StoreLoad。
(2)StoreLoad的意思
我们可以简单地认为让Store先于Load发生。例如两个在某个瞬间同时修改和读取主存中的一个共享变量,此时的读取操作将发生在修改之后。有了这样一种特征,就实现了最细粒度的锁,也是最轻量级的锁(在这里所提到的轻量的概念与JVM本身对悲观锁优化中所引导出的轻量级锁的概念不是同一个)。
不过,这样的方式仅仅能保证读的一瞬间确保线程读取到最新的数据,因此要进一步做到读取、修改、写入的动作是一致的,就将其升级为原子性。要达到原子性的效果,可以通过可见性、CAS自旋来完成,也可以通过synchronized来完成。
5.2.2 一些并发问题描述
前文中的一个简单例子提到了可见性问题,这个问题比较好模拟,但是还有一些非常不好模拟的并发问题,而且总是存在于一些细节上,本节我们就来描述一此并发问题。
(1)指令重排序
指令重排序可能是Java编译器在编译Java代码时对虚指令进行重排序,也可以是CPU对目标指令进行重排序,它们的目的当然都是为了高效(换句话说,我们自己写的代码对于计算机的解析和运行来讲顺序未必是最高效的)。
重排序在提升性能的同时,也给我们带来了许多麻烦,而这些麻烦通常带来的问题十分诡异。为了说明这样的问题,请大家先看图5-2。
图5-2 两个线程操作共享变量
根据图5-2描述的代码及调用关系,按照常理线程1执行init()方法将先创建一个B对象赋值给属性b,然后再将初始化参数inited设置为true(先抛开可见性问题),其他的线程会先通过isInited()方法判定对象内的属性是否已经被初始化好,然后再操作对象本身,这样对象的操作应当是安全的。
如果这里发生指令重排序,就未必安全了!假如inited=true先于其他属性被赋值前被执行,那么此时线程2通过isInited()方法得到返回值为true,但有可能对象内部的某些属性根本还没有初始化完成,从而导致许多不可预见的问题。
(1)重排序并不意味着程序中的所有代码是杂乱无序的,只是重排序那些没有相互依赖的代码,便于某些优化,我们可以认为在单线程中执行代码结果永远不会变(as-if-serial)。
(2)用Java写代码时本身不注重性能,但是并不意味着不注重较低的抽象层次,因为较低的抽象层次的优化可能影响到Java中大量频繁的简单操作的性能,这种时间的提升虽然很小,但有可能对Java整体的运行性能是有影响的。
(2)4字节赋值问题
在JVM中允许对一个非volatile的64位(8字节)变量赋值时,分解为两个32位(4字节)来完成,但并不是必须要一次性完成(从Java角度来理解,在虚指令中对变量的操作都以slot为单位,每个slot就是4字节)。
问题出来了,如果变量是long、double类型的数据,在赋值某个32位后,正好被另一个线程所读取,那么它读取出来的数据就可能是不可预见的结果。
(3)数据失效
正常的在JavaBean中提供大量的set、get是没有问题的,如果这样的对象提供给多线程使用就不一定了,用前面提到的可见性的道理来讲,当一个线程调用set后,其他的线程未必能看得到。
这似乎没什么大不了的,那么胖哥就说个有点小影响的例子。
假如某个赋值是一个应用平台的系统配置参数,它在内存中有一份拷贝,这份配置参数将会影响程序对某些金额的计算方法或业务流程,这时偶然发生了另一个线程看不到的情况,导致虽然有最新的配置,但是这个线程还在走老路,那么结果自然是错误的,完全由可能带来经济损失。
(4)非安全的发布
为了说明问题,请先看图5-3。
图5-3 对象发布逃逸
这里的引用a可以直接被外部使用,它希望有一个线程将其初始化后再让外部看到,但在未初始化好a以前,线程2可能对a直接进行使用了,那么自然是空指针。更加可怕的是另一种现象,就是此时a引用指向的是一个还未初始化完成的A对象,这个对象空间可能被创建了,但是它的内部属性的初始化还需要一个过程(例子中只有1个简单属性,在实际的程序中可能会有很多复杂属性),这是由于在JMM中并没有规定普通属性的赋值必须发生在构造方法的return语句之前。换句话说,A对象的构造方法内的属性赋值可以在return发生之后,而当A对象的构造方法发生return后,B类中的静态属性a就可以被赋值了,这样就会导致线程3在判定B类中的属性a不为空的情况下,使用这个对象来做操作,而这个对象依然有可能没有被初始化好。这种问题算是发布的一种“逃逸”问题,换句话说,就是程序访问了不该访问的内容,访问了可能还没准备好的内容。
并发问题的例子远不止这些,下面再做一点点补充。
例如,在内存中用数组或集合类来Cache一些数据,提供给应用服务器的多个线程访问使用,那么我们应当如何提供API给它们使用呢?
假如API返回整个集合类或数组,那么集合类或数组中的元素将有可能被多线程并发修改,这种修改自然就可能会存在并发的问题;如果返回集合类或数组中其中一个下标的元素理论上没有问题,但集合类或数组中所存放的元素也是对象,对象中包含了许多可变的属性,则该对象依然可能会被多线程并发地修改。
因此,为了避免并发问题,我们需要考虑一些场景,例如可以使用一份数据拷贝返回,或者返回不允许修改的代理API(Collections.unmodifiable相关API)。返回的数据拷贝,可以是一个集合类或数组的一个元素或者整个集合类或数组,而拷贝的深度需要根据业务来控制,对于复杂的包装对象,要保证绝对的线程安全性是十分麻烦的。如果要返回不允许修改的数据结构的代理操作类,则可以使用Collections提供的unmodifiable相关的静态方法,例如Collections.unmodifiableList(List)将返回不可修改的List对象,同样的,该API限制仅仅局限于List本身不能被修改,若List内部的元素是数组、集合类、包含许多非final属性的对象,则依然可以在获取元素后修改相应的内容。
下面再举个通过子类“逃逸”的例子。
从图5-3中可以看到一种通过“逃逸”的例子。很多时候场景千变万化,例如在子类中提供了自定义的构造方法,在构造方法中将this 引用交给另一个线程所访问或作为任务的属性提交给线程池,它们可以通过this访问这个对象的一些方法或属性,而此时对象可能还没有初始化完成相应的属性。
描述了这么多问题,或许我们不会这样写代码,或许有的牛人会说“根本就不该这样写代码”,不过反过来讲,其实我们是在就问题而探讨问题,或许真正的程序会隐藏得更深,但道理基本是一致的。换句话说,我们要理解内在机制,当某一天遇到类似的问题时,可以有能力去分析和解决。
接下来,我们就介绍一些Java面对各种并发问题提供的语法支持,或者称作JVM层面上的功能支持。
5.2.3 volatile
变量被volatile关键字修饰后,貌似就会避开前面提到的部分问题。在前面我们用一个SVN上传/下载的理想例子来说明,要做到完美就得牺牲一些性能,虽然volatile它被誉为“最轻量级的锁”,但它依旧比普通变量的访问慢一些。之所以说它轻,是因为它只是在读这个瞬间要求一个简单的顺序,而不是一个变量上的原子读写,或者在一段代码上的同步。
在JDK 1.5以前,volatile变量并没有完全实现轻量级的锁,不过JDK 1.5对可见性做了更为严格的定义,因此我们可以开始放心使用。不过也因此volatile修饰的变量的性能可能会有所下降。JDK也同样对synchronized做了各种轻量级、偏向的优化,简单来讲是Java的作者们开始意识到一些Java锁的场景,认为在很多场景下锁的开销是不需要完全使用悲观锁的,因此做了很多改造和优化,不过从理论上讲,其开销应该不会比volatile小(因为它最少会保证一个原子变量的读写一致性,而volatile不需要)。另外,synchronized通常是基于代码段的,开销变小仅仅是加锁的过程开销,而并非锁所包含的代码区域也会加快运行速度,相关的代码区域依然被串行访问,因此不要期望于系统的锁优化为我们解决所有的问题,很多优化原则依然需要根据我们对于锁粒度本身的理解,以及结合相关的业务背景来综合分析,才能得出答案。
volatile变量也会像普通变量那样从主存中拷贝到各个线程中去操作,区别在于它要求实现StoreLoad指令屏障(当然JVM也未必一定要用这种指令来实现,前文中提到的4种指令屏障只要对应的处理器支持,也可以采用具体的平台来优化),由此我们可以简单地认为volatile的第一个作用就是:保证多线程中的共享变量是始终可见的(但这并不保证volatile引用对象内部的属性是完全可见的)。
在JSR-133中对volatile语义的定义进行了增强(主要是为了真正实现更简单的锁),要求在对volatile变量进行读/写操作时,其前后的指令在某些情况下不允许进行重排序(不论是编译时重排序还是处理器重排序,都不允许)。对于这种限制分为如下几种情况。
(1)如果是一条对volatile变量进行赋值操作的代码,那么在该代码前面的任何代码不能与这个赋值操作交换顺序。在图5-2中,一个inited变量如果使用volatile修饰,那么就能够达到目的,因为它能确保前面属性的赋值在inited=true发生之前。
我们需要注意两点:
◎ 如果这个操作后有普通变量的读写操作,则是可以与它交换顺序的。
◎ 在这个动作之前的指令相互之间还是可以重排序的,只是不能排序到该动作后面。它就像一个向前的隔板,隔板前面的多个动作依然可以被重排序,隔板一边的普通变量可以进入另一边,以便于做更多的优化。
(2)如果是一条读取volatile变量的代码,则正好相反,相当于隔板翻了一个面,在它后面的操作动作不允许与它交换顺序,之后的多个动作依然可以重排序,在它之前的普通变量的操作动作也可以与它交换顺序。
(3)普通变量的读写操作相互之间是可以重排序的,只要不影响它们之间的逻辑语义顺序就可以重排序。但是如果普通变量的读/写操作遇上了volatile变量的操作,就需要遵循前两个基本原则。
(4)如果两个volatile变量的读/写操作都在一段代码中,则依然遵循前两个基本原则,此时无论两者之间读/写顺序如何,都肯定不会重排序。
这里反复强调的顺序,大家可能会有所疑惑,由于它的重要性,也同时帮助大家理解,下面举几个真实代码的例子。
◎ 一个变量被赋值后,经过某些读写操作,再赋值给另一个变量,这个顺序是不会被打乱的。
◎ 程序中try、catch、finally代码肯定不会交换顺序来执行,比如:try部分的代码还没执行完,也没有抛出异常,就执行finally的代码,这种情况是不会发生的,否则就全部乱套了。我们可以用前面提到的“始终保持与单线程中的最终计算结果是相同的”这句话来理解这个逻辑顺序问题。
综上所述,volatile修饰变量的第2个作用是:防止相关性代码的重排序,从指令级别达到了轻量级锁的目的。除此之外,volatile还有一个重要的作用是解决前面提到的4字节赋值问题,对于volatile修饰的变量,必须一次性赋值。
volatile内在到底是怎么回事?或许我们可以输出某些平台上的汇编指令来看看它到底与普通变量的操作有何特殊性。
输出汇编指令?这在Java领域很少听到,不过确实可以做到,只是有点麻烦。首先要做的事情就是下载插件,由于与平台相关,所以插件也与平台相关。打开地址https://kenai. com/projects/base-hsdis/downloads,可以下载到很多种操作的插件,Windows 32bit版本在http://hllvm.group.iteye.com/ 上可以找到。
将插件下载好以后,放在哪里?在Linux JVM中放在$JAVA_HOME/jre/lib/amd64/server及client两个目录下,并记得执行chmod +x相应的命令;在Windows JVM中放在%JAVA_ HOME%/jre/bin/server及client两个目录中,若只存在一个目录,则只放一个。
接下来,我们需要编写一段简单的程序,用于检测不同变量的赋值。
代码清单5-6 测试输出汇编指令
[code lang=”java”]
public class AssemTest {
int a, b;
volatile int c, d;
public static void main(String[] args) throws Exception {
new AssemTest().test();
}
public void test() {
a = 1;
b = 2;
c = a;
d = b;
b = 3;
c = 2;
}
}
[/code]
该代码通过javac编译为Class文件,下面使用java命令携带相应的参数来输出汇编指令。
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp -XX:CompileCommand=compileonly,*AssemTest.test AssemTest
在Linux系统上输出结果如图5-4所示。
从图5-4所示的汇编指令中可以看出,汇编指令中出现了lock addl $0x0操作,它是通过某个较低抽象层次的锁实现了相应的屏障。
图5-4 输出Java在对应平台上的汇编指令
5.2.4 final
在JMM中要求final域(属性)的初始化动作必须在构造方法return之前完成。换言之,一个对象创建以及将其赋值给一个引用是两个动作,对象创建还需要经历分配空间和属性初始化的过程,普通的属性初始化允许发生在构造方法return之后(指令重排序)。
似乎这个问题变得很可怕,因为在Java程序中得到的对象竟然有可能还没有执行完构造方法内的属性赋值,但在大部分情况下,对象的使用都是在线程内部定义的,在单线程中是绝对可靠的,或者说在单线程中要求使用对象引用时,该对象已经被初始化好。但如果在此过程中有另一个线程通过这个未初始化好的对象引用读取相应的属性,那么就可能读取到的并不是真正想要的值。在Java中final可以保证这一点,所以它可以避免这种类型的逃逸问题。
但是它并不能完全解决所有的逃逸问题,而只是确保在构造方法return以前是会被初始化的,无法确保不与其他的指令进行重排序,比如下面的代码:
[code lang=”java”]
private static TestObject testObject = null;
final int a;
public 构造方法() {
a = 100;
testObject = this; //这个地方可能和a=100发生指令重排序
}
public static void read() {
if(testObject != null) {
//对变量testObject.a做操作
}
}
[/code]
如果有另一个线程调用静态方法read(),则可能得到testObject非空值,而此时有可能a=100这个动作还未执行(因为它可以与testObject = this进行重排序),那么操作的数据就将是错误的。
进一步探讨:如果final所修饰的不是普通变量,而是数组、对象,那么它能保证自己本身的初始化在其外部对象的构造方法返回之前,但是它本身作为对象,对内部的属性是无法保证的。如果是某些具有标志性的属性,则需要根据实际情况做进一步处理,才可以达到线程安全的目的。
经过JSR-133对final进行语义增强后,我们就可以比较放心地使用final语法了。但是我们想看看构造方法还没做完,变量是什么样子呢?普通变量和final变量到底又有什么区别呢?下面我们就写一段和并发编程没多大关系的代码来跑一跑看看。
代码清单5-7 构造方法未结束,看看属性是什么样子
[code lang=”java”]
public class FinalConstructorTest {
static abstract class A {
public A() {
display();
}
public abstract void display();
}
static class B extends A {
private int INT = 100;
private final int FINAL_INT = 100;
private final Integer FINAL_INTEGER = 100;
private String STR1 = "abc";
private final String FINAL_STR1 = "abc";
private final String FINAL_STR2 = new String("abc");
private final List<String> FINAL_LIST = new ArrayList<String>();
public B() {
super();
System.out.println("abc");
}
public void display() {
System.out.println(INT);
System.out.println(FINAL_INT);
System.out.println(FINAL_INTEGER);
System.out.println(STR1);
System.out.println(FINAL_STR1);
System.out.println(FINAL_STR2);
System.out.println(FINAL_LIST);
}
}
public static void main(String []args) {
new B();
}
}
[/code]
在这段代码中,我们跳开了构造方法返回之前对final的初始化动作,而是在构造方法内部去输出这些final属性。这段代码的输出结果可能会让我们意想不到,大家可以自行测试,如果在测试过程中使用断点跟踪去查看这些数据的值,则可能在断点中看到的值与实际输出的值还会有所区别,因为断点也是通过另一个线程去看对象的属性值的,看到的对象可能正好是没有初始化好的对象。
这样看来,volatile和final在程序中必不可少吗?当然不是!
如果每个属性都使用这样的修饰符,那么系统就没有必要设置这样的修饰符了!其实它们是有一定的性能开销的!我们关注的是代码是否真的有并发问题,如果数据本身就是某些只读数据,或者这些Java对象本身就是线程所私有的局部变量或类似于ThrealLocal的变量,那么就没有必要使用这些修饰符了。
提到final,我们再补充一个相关的话题(该话题与并发编程无关)。当在方法中使用匿名内部类时,匿名内部类的方法要直接使用外部方法中的局部变量,这个局部变量必须用final来声明才可以被使用。很多人会问这到底是为什么?伪代码如下:
[code lang=”java”]
public void test() {
final int a = 100;//这个a必须定义为final,才能被匿名子类直接使用
new A() {
public void display() {
System.out.println(a);
}
}
其他操作
}
[/code]
这其实是对一个语法的疑问,本身没什么好解释的,但是如果非要解释,或许我们可以从这个角度来理解:JVM本身也是一种软件平台,它设计了一种语法规则,自然也有它的局限性和初衷。在编译时,这个地方会自动生成一个匿名内部类,而本地变量a的作用域是在方法test()中,它如何能用到另一个内部类中呢?
其中一种方式是参数传递;另一种方式就是作为一个属性存在于匿名内部类对象中。不论哪一种方式都会在这个匿名内部类对象中有一份数据拷贝(如果本地变量是引用,那么拷贝将是引用的值),不过这似乎与外部定义本地变量时是否定义为final没有关系。
JVM在设计时并不知道我们的代码会怎么写,或者说它不明确在所创建的匿名内部类中到底会做什么,例如在代码中完全可以在内部创建一个线程来使用这个变量,或者创建一个任务提交给线程池来使用这个变量。如果是这样,匿名内部类的运行将与该方法本身的运行处于两个线程中,当外部方法可能已经结束时,那么相应的局部变量的作用域已经结束,自动会被回收,要保证匿名内部类一直可以使用该变量,就只能用拷贝的方法,这似乎还是与final没有多大关系。
我们现在回过头来回答:为何外部方法必须使用final定义这个这个变量?我们将上述结论反过来想,如果这个属性不是final修饰的,在匿名内部类中使用“同名”的变量操作,并且可以对它做任意修改,自然外部也应当能感知到。但是事实上不能感知到,因为这是一份数据拷贝,就像传递的参数一样,不是原来的数据。胖哥认为这种语法的设计手段是为了避免误解,语法上强制约束它为final修饰。
如果这里的a是一个引用,那么就只会拷贝引用值,而不是整个对象的内容,在内部修改引用所指向的对象不会影响外部,外部的final也无法约束其改变,但如果改变其对象内部的属性,只要外部还会使用这个对象,那么就会受到影响。
5.2.5 栈封闭
栈封闭算是一种概念,也就是线程操作的数据都是私有的,不会与其他的线程共享数据。简单来说,如果每个线程所访问的JVM区域是隔离的,那么这个系统就像一个单线程系统一样简单了,我们把它叫作栈封闭。这样说是不是有点抽象,下面讲实际的例子。
通常在做Web开发时不需要自己去关注多线程的各种内在,因为容器给我们做好了,从前端请求就给业务处理分配好了数据,我们无须关注那些并发的过程,Web容器会自动给我们提供私有的Reqeust、Response对象的处理,因为它不会被其他的线程所占用,所以可以放心使用,它是线程绝对安全的(当使用Session或ServletContext时,它也在内部给你封装好了并发的控制)。
但这并不意味着程序永远不关注多线程与异步的问题,当需要并发去访问某些共享缓存数据时,当需要去操作共享文件数据时,当自定义多线程去并发做一些任务时,都必然会用到这些基本的知识体系来作为支撑,否则代码出现某些诡异的问题还不知道怎么回事。
比如在一个项目中,使用了Spring注入的DAO层,大家都应该知道Spring生成的对象默认是“单例”的,也就是一个类只会生成一个对应的实例。在这个DAO的许多方法中,使用StringBuilder进行SQL拼接,在DAO里面定义了StringBuilder,认为这个变量可以提供给许多的DAO层的方法共同使用,以节约空间,每个相应的方法都是对它做一个或多个append操作后,通过toString()获取结果String对象,最后再把这个StringBuilder清空。
先抛开并发本身的问题,这样做也根本节约不了什么空间,因为这个对象将拥有与这个DAO一样永久的生命周期占用内存,由于DAO是“单例”的,所以相当于永久的生命周期。我们节约空间的方式通常是希望它短命,在Young空间就干掉它,而不是让它共享。
继续看有什么并发的问题。虽然这个StringBuilder不是static修饰的,但由于它所在的这个DAO对象实例是“单例”的,由Spring控制生成,所以它几乎等价于全局对象。它至少会在这个类里面的所有方法被访问时共享,就算这个类里面只有一个方法也会有并发问题(因为同一个方法是可以被多个线程同时访问的,为何?因为它是代码段,程序运行时只需要从这里获取到指令列表即可,或者反过来理解,如果所有的代码都不能并行访问,那么多线程程序就完全被串行化了)。
如果数据区域发生共享就有问题了,多个线程可能在同时改一个数据段,这样没有任何安全策略的数据段,最终结果会是什么样子,谁也不清楚。本例中提到的StringBuilder就是一个共享的数据区域,假如有两个线程在append(),然后一个线程toString()得到的结果将有可能是两个线程共同写入的数据,它在操作完成后可能还会将数据清空,那么另一个线程就可能拿到一个空字符串甚至于更加诡异的结果。这样的程序很明显是有问题的。
如果改成StringBuffer,是否可行?
答曰:StringBuffer是同步的,但是并不代表它在业务上是绝对安全的,认为它安全是因为它在每一次做append()类似操作时都会加上synchronized的操作,但是在实际的程序中是可以对StringBuffer进行多次append()操作的,在这些append()操作之间可能还会有其他的代码步骤,StringBuffer可以保证每次append()操作是线程安全的,但它无法保证多线程访问时进行多次append()也能得到理想的结果。
难道我们还要在外层加一个锁来控制?
如果是这样的话,新的问题就出现了,这个类所有的方法访问到这里都是串行的,如果所有的DAO层都是这样的情况,抛开锁本身的开销,此时系统就像单线程系统一样在运行,外部的并发访问到来时,系统将奇慢无比。如果访问过大,就会堆积大量的线程阻塞,以及线程所持有的上下文无法释放,而且会越堆积越多,后果可想而知。
锁的开销是巨大的,它对于并发编程中的性能是十分重要的,于是许多大牛开始对无锁化的问题有了追求,或者说尽量靠近无锁化。在大多数情况下我们希望事情是乐观的,希望使用尽量细粒度化的锁机制,不过对于大量循环调用锁的情况会反过来使用粗粒度化的锁机制,因为加锁的开销本身也是巨大的。
关于栈封闭,除了使用局部变量外,还有一种方式就是使用ThreadLocal,ThreadLocal使用一种变通的方式来达到栈封闭的目的,具体的请参看下一小节的内容。
5.2.6 ThreadLocal
虽然ThreadLocal与并发问题相关,但是许多程序员仅仅将它作为一种用于“方便传参”的工具,胖哥认为这也许并不是ThreadLocal设计的目的,它本身是为线程安全和某些特定场景的问题而设计的。
ThreadLocal是什么呢!
每个ThreadLocal可以放一个线程级别的变量,但是它本身可以被多个线程共享使用,而且又可以达到线程安全的目的,且绝对线程安全。
例如:
[code lang=”java”]
public final static ThreadLocal<String> RESOURCE = new ThreadLocal<String>();
[/code]
RESOURCE代表一个可以存放String类型的ThreadLocal对象,此时任何一个线程可以并发访问这个变量,对它进行写入、读取操作,都是线程安全的。比如一个线程通过RESOURCE.set(“aaaa”);将数据写入ThreadLocal中,在任何一个地方,都可以通过RESOURCE.get();将值获取出来。
但是它也并不完美,有许多缺陷,就像大家依赖于它来做参数传递一样,接下来我们就来分析它的一些不好的地方。
为什么有些时候会将ThreadLocal作为方便传递参数的方式呢?例如当许多方法相互调用时,最初的设计可能没有想太多,有多少个参数就传递多少个变量,那么整个参数传递的过程就是零散的。进一步思考:若A方法调用B方法传递了8个参数,B方法接下来调用C方法->D方法->E方法->F方法等只需要5个参数,此时在设计API时就涉及5个参数的入口,这些方法在业务发展的过程中被许多地方所复用。
某一天,我们发现F方法需要加一个参数,这个参数在A方法的入口参数中有,此时,如果要改中间方法牵涉面会很大,而且不知道修改后会不会有Bug。作为程序员的我们可能会随性一想,ThreadLocal反正是全局的,就放这里吧,确实好解决。
但是此时你会发现系统中这种方式有点像在贴补丁,越贴越多,我们必须要求调用相关的代码都使用ThreadLocal传递这个参数,有可能会搞得乱七八糟的。换句话说,并不是不让用,而是我们要明确它的入口和出口是可控的。
诡异的ThreadLocal最难琢磨的是“作用域”,尤其是在代码设计之初很乱的情况下,如果再增加许多ThreadLocal,系统就会逐渐变成神龙见首不见尾的情况。有了这样一个省事的东西,可能许多小伙伴更加不在意设计,因为大家都认为这些问题都可以通过变化的手段来解决。胖哥认为这是一种恶性循环。
对于这类业务场景,应当提前有所准备,需要粗粒度化业务模型,即使要用ThreadLocal,也不是加一个参数就加一个ThreadLocal变量。例如,我们可以设计几种对象来封装入口参数,在接口设计时入口参数都以对象为基础。
也许一个类无法表达所有的参数意思,而且那样容易导致强耦合。
通常我们按照业务模型分解为几大类型对象作为它们的参数包装,并且将按照对象属性共享情况进行抽象,在继承关系的每一个层次各自扩展相应的参数,或者说加参数就在对象中加,共享参数就在父类中定义,这样的参数就逐步规范化了。
我们回到正题,探讨一下ThreadLocal到底是用来做什么的?为此我们探讨下文中的几个话题。
(1)应用场景及使用方式
为了说明ThreadLocal的应用场景,我们来看一个框架的例子。Spring的事务管理器通过AOP切入业务代码,在进入业务代码前,会根据对应的事务管理器提取出相应的事务对象,假如事务管理器是DataSourceTransactionManager,就会从DataSource中获取一个连接对象,通过一定的包装后将其保存在ThreadLocal中。并且Spring也将DataSource进行了包装,重写了其中的getConnection()方法,或者说该方法的返回将由Spring来控制,这样Spring就能让线程内多次获取到的Connection对象是同一个。
为什么要放在ThreadLocal里面呢?因为Spring在AOP后并不能向应用程序传递参数,应用程序的每个业务代码是事先定义好的,Spring并不会要求在业务代码的入口参数中必须编写Connection的入口参数。此时Spring选择了ThreadLocal,通过它保证连接对象始终在线程内部,任何时候都能拿到,此时Spring非常清楚什么时候回收这个连接,也就是非常清楚什么时候从ThreadLocal中删除这个元素(在9.2节中会详细讲解)。
从Spring事务管理器的设计上可以看出,Spring利用ThreadLocal得到了一个很完美的设计思路,同时它在设计时也十分清楚ThreadLocal中元素应该在什么时候删除。由此,我们简单地认为ThreadLocal尽量使用在一个全局的设计上,而不是一种打补丁的间接方法。
了解了基本应用场景后,接下来看一个例子。定义一个类用于存放静态的ThreadLocal对象,通过多个线程并行地对ThreadLocal对象进行set、get操作,并将值进行打印,来看看每个线程自己设置进去的值和取出来的值是否是一样的。代码如下:
代码清单5-8 简单的ThreadLocal例子
[code lang=”java”]
public class ThreadLocalTest {
static class ResourceClass {
public final static ThreadLocal<String> RESOURCE_1 =
new ThreadLocal<String>();
public final static ThreadLocal<String> RESOURCE_2 =
new ThreadLocal<String>();
}
static class A {
public void setOne(String value) {
ResourceClass.RESOURCE_1.set(value);
}
public void setTwo(String value) {
ResourceClass.RESOURCE_2.set(value);
}
}
static class B {
public void display() {
System.out.println(ResourceClass.RESOURCE_1.get()
+ ":" + ResourceClass.RESOURCE_2.get());
}
}
public static void main(String []args) {
final A a = new A();
final B b = new B();
for(int i = 0 ; i < 15 ; i ++) {
final String resouce1 = "线程-" + I;
final String resouce2 = " value = (" + i + ")";
new Thread() {
public void run() {
try {
a.setOne(resouce1);
a.setTwo(resouce2);
b.display();
}finally {
ResourceClass.RESOURCE_1.remove();
ResourceClass.RESOURCE_2.remove();
}
}
}.start();
}
}
}
[/code]
关于这段代码,我们先说几点。
◎ 定义了两个ThreadLocal变量,最终的目的就是要看最后两个值是否能对应上,这样才有机会证明ThreadLocal所保存的数据可能是线程私有的。
◎ 使用两个内部类只是为了使测试简单,方便大家直观理解,大家也可以将这个例子的代码拆分到多个类中,得到的结果是相同的。
◎ 测试代码更像是为了方便传递参数,因为它确实传递参数很方便,但这仅仅是为了测试。
◎ 在finally里面有remove()操作,是为了清空数据而使用的。为何要清空数据,在后文中会继续介绍细节。
测试结果如下:
线程-6: value = (6)
线程-9: value = (9)
线程-0: value = (0)
线程-10: value = (10)
线程-12: value = (12)
线程-14: value = (14)
线程-11: value = (11)
线程-3: value = (3)
线程-5: value = (5)
线程-13: value = (13)
线程-2: value = (2)
线程-4: value = (4)
线程-8: value = (8)
线程-7: value = (7)
线程-1: value = (1)
大家可以看到输出的线程顺序并非最初定义线程的顺序,理论上可以说明多线程应当是并发执行的,但是依然可以保持每个线程里面的值是对应的,说明这些值已经达到了线程私有的目的。
不是说共享变量无法做到线程私有吗?它又是如何做到线程私有的呢?这就需要我们知道一点点原理上的东西,否则用起来也没那么放心,请看下面的介绍。
(2)ThreadLocal内在原理
从前面的操作可以发现,ThreadLocal最常见的操作就是set、get、remove三个动作,下面来看看这三个动作到底做了什么事情。首先看set操作,源码片段如图5-5所示。
图5-5 ThreadLcoal.set源码片段
图5-5中的第一条代码取出了当前线程t,然后调用getMap(t)方法时传入了当前线程,换句话说,该方法返回的ThreadLocalMap和当前线程有点关系,我们先记录下来。进一步判定如果这个map不为空,那么设置到Map中的Key就是this,值就是外部传入的参数。这个this是什么呢?就是定义的ThreadLocal对象。
代码中有两条路径需要追踪,分别是getMap(Thread)和createMap(Thread , T)。首先来看看getMap(t)操作,如图5-6所示。
图5-6 getMap(Thread)操作
在这里,我们看到ThreadLocalMap其实就是线程里面的一个属性,它在Thread类中的定义是:
ThreadLocal.ThreadLocalMap threadLocals = null;
这种方法很容易让人混淆,因为这个ThreadLocalMap是ThreadLocal里面的内部类,放在了Thread类里面作为一个属性而存在,ThreadLocal本身成为这个Map里面存放的Key,用户输入的值是Value。太乱了,理不清楚了,画个图来看看(见图5-7)。
简单来讲,就是这个Map对象在Thread里面作为私有的变量而存在,所以是线程安全的。ThreadLocal通过Thread.currentThread()获取当前的线程就能得到这个Map对象,同时将自身作为Key发起写入和读取,由于将自身作为Key,所以一个ThreadLocal对象就能存放一个线程中对应的Java对象,通过get也自然能找到这个对象。
图5-7 Thread与ThreadLocal的伪代码关联关系
如果还没有理解,则可以将思维放宽一点。当定义变量String a时,这个“a”其实只是一个名称(在第3章中已经说到了常量池),虚拟机需要通过符号表来找到相应的信息,而这种方式正好就像一种K-V结构,底层的处理方式也确实很接近这样,这里的处理方式是显式地使用Map来存放数据,这也是一种实现手段的变通。
现在有了思路,继续回到上面的话题,为了验证前面的推断和理解,来看看createMap方法的细节,如图5-8所示。
图5-8 createMap操作
这段代码是执行一个创建新的Map的操作,并且将第一个值作为这个Map的初始化值,由于这个Map是线程私有的,不可能有另一个线程同时也在对它做put操作,因此这里的赋值和初始化是绝对线程安全的,也同时保证了每一个外部写入的值都将写入到Map对象中。
最后来看看get()、remove()代码,或许看到这里就可以认定我们的理论是正确的,如图5-9所示。
图5-9 get()/remove()方法的代码片段
给我们的感觉是,这样实现是一种技巧,而不是一种技术。
其实是技巧还是技术完全是从某种角度来看的,或者说是从某种抽象层次来看的,如果这段代码在C++中实现,难道就叫技术,不是技巧了吗?当然不是!胖哥认为技术依然是建立在思想和方法基础上的,只是看实现的抽象层次在什么级别。就像在本书中多个地方探讨的一些基础原理一样,我们探讨了它的思想,其实它的实现也是基于某种技巧和手段的,只是对程序封装后就变成了某种语法和API,因此胖哥认为,一旦学会使用技巧思考问题,就学会了通过技巧去看待技术本身。我们应当通过这种设计,学会一种变通和发散的思维,学会理解各种各样的场景,这样便可以积累许多真正的财富,这些财富不是通过某些工具的使用或测试就可以获得的。
ThreadLocal的这种设计很完美吗?
不是很完美,它依然有许多坑,在这里对它容易误导程序员当成传参工具就不再多提了,下面我们来看看它的使用不当会导致什么技术上的问题。
(3)ThreadLocal的坑
通过上面的分析,我们可以认识到ThreadLocal其实是与线程绑定的一个变量,如此就会出现一个问题:如果没有将ThreadLocal内的变量删除(remove)或替换,它的生命周期将会与线程共存。因此,ThreadLocal的一个很大的“坑”就是当使用不当时,导致使用者不知道它的作用域范围。
大家可能认为线程结束后ThreadLocal应该就回收了,如果线程真的注销了确实是这样的,但是事实有可能并非如此,例如在线程池中对线程管理都是采用线程复用的方法(Web容器通常也会采用线程池),在线程池中线程很难结束甚至于永远不会结束,这将意味着线程持续的时间将不可预测,甚至与JVM的生命周期一致。那么相应的ThreadLocal变量的生命周期也将不可预测。
也许系统中定义少量几个ThreadLocal变量也无所谓,因为每次set数据时是用ThreadLocal本身作为Key的,相同的Key肯定会替换原来的数据,原来的数据就可以被释放了,理论上不会导致什么问题。但世事无绝对,如果ThreadLocal中直接或间接包装了集合类或复杂对象,每次在同一个ThreadLocal中取出对象后,再对内容做操作,那么内部的集合类和复杂对象所占用的空间可能会开始膨胀。
抛开代码本身的问题,举一个极端的例子。如果不想定义太多的ThreadLocal变量,就用一个HashMap来存放,这貌似没什么问题。由于ThreadLocal在程序的任何一个地方都可以用得到,在某些设计不当的代码中很难知道这个HashMap写入的源头,在代码中为了保险起见,通常会先检查这个HashMap是否存在,若不存在,则创建一个HashMap写进去;若存在,通常也不会替换掉,因为代码编写者通常会“害怕”因为这种替换会丢掉一些来自“其他地方写入HashMap的数据”,从而导致许多不可预见的问题。
在这样的情况下,HashMap第一次放入ThreadLocal中也许就一直不会被释放,而这个HashMap中可能开始存放许多Key-Value信息,如果业务上存放的Key值在不断变化(例如,将业务的ID作为Key),那么这个HashMap就开始不断变长,并且很可能在每个线程中都有一个这样的HashMap,逐渐地形成了间接的内存泄漏。曾经有很多人吃过这个亏,而且吃亏的时候发现这样的代码可能不是在自己的业务系统中,而是出现在某些二方包、三方包中(开源并不保证没有问题)。
要处理这种问题很复杂,不过首先要保证自己编写的代码是没问题的,要保证没问题不是说我们不去用ThreadLocal,甚至不去学习它,因为它肯定有其应用价值。在使用时要明白ThreadLocal最难以捉摸的是“不知道哪里是源头”(通常是代码设计不当导致的),只有知道了源头才能控制结束的部分,或者说我们从设计的角度要让ThreadLocal的set、remove有始有终,通常在外部调用的代码中使用finally来remove数据,只要我们仔细思考和抽象是可以达到这个目的的。有些是二方包、三方包的问题,对于这些问题我们需要学会的是找到问题的根源后解决,关于二方包、三方包的运行跟踪,可参看第3.7.9节介绍的BTrace工具。
补充:在任何异步程序中(包括异步I/O、非阻塞I/O),ThreadLocal的参数传递是不靠谱的,因为线程将请求发送后,就不再等待远程返回结果继续向下执行了,真正的返回结果得到后,处理的线程可能是另一个。
原创文章,转载请注明: 转载自并发编程网 – ifeve.com本文链接地址: 《Java特种兵》5.2 线程安全
暂无评论