您的位置:首页 > 编程语言 > Java开发

java特种兵读书笔记(5-2)——并发之线程安全

2016-02-01 17:09 537 查看
线程安全与处理器核数

多核处理器会有可见性问题引发的线程安全问题。

单核处理器不会有这种问题,但是也会有一致性问题,因为CPU有时间片原则,还会重排序等等。

并发内存模型

为了提升性能,CPU会cache许多数据。在多核CPU下,每个CPU都有自己独立的cache,每个CPU都可以修改同一个单元数据,有自己的一套缓存一致性协议——从CPU的角度认为某些来自主存的数据的读取和修改需要一定的一致性保证。

JVM中,每个线程的私有栈在使用共享数据时,会先将共享数据拷贝到栈顶进行运算,这个数据是一个副本,所以存在多个线程修改同一个内存单元的一致性问题。

可见性问题

public class Test{

public static class InnerTest extends Thread {

private boolean ready;

private int number;

public void run() {while(!ready){};}

public void readyOn(){ready = true;}}

public static void main(String[] args) {

InnerTest test = new InnerTest();test.start();Thread.sleep(300);test.readyOn();

System.out.println(test.ready);

}

}

这段代码看似没问题,但是如果运行在server模式下(服务器端程序大部分运行在server模式下——可以在tomcat的startenv.sh启动文件中通过增加-server来配置)代码会走入死循环,主线程输出了结果,子线程一直都不结束。

因为在同一个对象内部,一个线程将该对象的属性修改后,另一个线程未必能马上看到结果,这就是并发模型中的可见性问题。因为,普通变量的修改并不需要立即写回到主存,线程读取时也不需要每次都从主存中去读。

解决方法:

①在循环中增加Thread.yield()操作可以让线程让步CPU,进而很可能到主存中读取ready值。

②可以在循环中加一条System.out.println()语句打印ready。

③将ready变量加一个volatile修饰符。

④早期做法都是加悲观锁,加synchronized等等。这种方式开销大,性能差,需要更细粒度的控制方式来提升性能。

主存与工作内存

宏观上将JVM划分为堆和栈两个部分。

堆内存:是java对象使用的区域,JVM将其定义为主存。

栈空间:包括局部变量,操作数栈,当前方法的常量池指针,当前方法的返回地址等信息。该空间是最接近CPU运算的,是每个线程私有的空间。调用某方法开始时给私有栈分配空间,方法内部再调用方法还会继续使用相应的栈空间,方法返回时回收相应空间。该空间叫做Working memory——工作内存。

如果栈内部包含的局部变量是引用,那么仅仅是引用值在栈中,会占用一个引用本身的大小,具体的对象还是在堆中,对象本身的大小与栈空间的使用无关。

工作内存与主存之间通过read/write来交互,当工作内存中数据需要计算时,会发生load/store操作。load操作将本地变量推倒栈顶,用来给CPU调度运算,store就是将栈顶数据写入本地变量。本地变量(局部变量)通常不存在一致性问题,因为它的定义本身就归属于线程运行时,生命周期由响应代码块决定。

所以对于代码一致性,我们更关心多线程对主存数据的读写操作。

load/store/read/write

load操作发生在read之后(两个之间可以有其他指令)。

普通变量的修改未必会立即发生store,但是发生store就会发生write操作。

有了这个准则之后,我们就不太关心read和write了。因为load发生之前一定有read,store之后一定有write。

如果代码在运行过程中,一个线程去读,一个线程去写,就会出现许多奇怪的现象。比如程序中读到了老的数据,或者很久甚至永远都读不到最新的数据,这类问题就是可见性问题。

StoreLoad:

两个在同一瞬间同时修改和读取主存中的一个共享变量,此时的读取操作将发生在修改操作后面,这样就实现了最细力度的锁。

但是这样只能保证读的一瞬间确保线程读取到最新的数据,如果需要进一步做到读取,修改,写入动作一致,就需要升级为原子级别。可以通过可见性,CAS自旋来完成,也可通过synchronized来完成。

指令重排序

java编译器在编译java代码时,对虚指令进行重排序。也可以是CPU对目标指令进行重排序,它们的目的都是为了高效。因为我们自己写代码,对于计算机来解析和运行来说顺序未必是最高效的。

比如在一个线程中:

int a=0;boolean isAZero=true;

a=2;isAZero=false;

在另一个线程中:

if(!isAZero){System.out.println(1/a)}

如果重排序,isAZero=true先于a=2赋值语句执行,那么另一个线程的判断并且进行除法的运算就会有异常。

注意:重排序并不意味着程序中所有的代码都是杂乱无章的,它只重排序那些没有相互依赖的代码,便于某些优化。我们可以认为在单线程中执行结果永远不变(as-if-seirial)。

四字节赋值问题

JVM允许对一个非volatile的64位(8字节)变量赋值时,分解为两个32位(4字节)来完成,但并不是必须一次性完成。从java角度,在虚指令中对变量的操作都是以slot为单位的,每个slot就是4个字节。

如果变量时long或者double,那么在赋值某个32位之后,正好被另一个线程读取,那么它读取出来的数据就可能是不可预见的结果。

数据失效

在正常的JavaBean中提供大量的get和set方法是没有问题的,但是如果这样的对象提供给多线程使用就不一定了。由于可见性的原因,当一个线程调用set方法之后,其他的线程未必能看到。

非安全的发布

构造方法中的赋值问题。

JMM没有规定普通属性的赋值一定要发生在构造方法的return语句之前,即A对象的构造方法内的属性赋值可以在return发生之后,此时该对象空间可能被创建了,但是内部属性初始化还需要一个过程。

这个问题算是发布的一种逃逸问题,即程序访问了不该访问的内容,访问了可能还没有准备好的内容。

集合类和数组的线程问题

集合类或者数组中的元素可能被多线程并发修改,这种修改会引起并发问题。

因为集合类或者数组中存放的元素也是对象,对象中包含了很多可变属性,该对象可能被多线程并发修改。

解决方法:

可以使用一份数据拷贝返回,返回不允许修改的代理API(Collections.unmodifiable相关API)。返回的数据拷贝可以是单独的元素也可以是整个集合类或数组。拷贝的深度需要根据业务来控制。该API限制仅仅局限于List本身不能被修改,其中的元素如果获取出来,其属性内容还是可以被修改的。

volatile与可见性和4字节赋值问题

解决可见性问题:

变量被volatile修饰之后,就会避开上面提到的问题。要做到没有并发问题,就需要牺牲一些性能。

虽然volatile被誉为最轻量级的锁,但它依旧比普通的变量的访问慢一些。

为什么轻?因为它只在读这个瞬间要求一个简单的顺序,而不是变量上的原子读写(atomic),或者在代码上同步(synchronized)。

JDK对synchronized做了轻量级、偏向的优化,因为一些java场景下锁的开销是不需要完全使用悲观锁的。不过理论上,这种开销不会比volatile小(因为它至少会保证一个原子变量的读写一致性,volatile不需要)。而且synchronized是基于代码片段的,开销变小是指加锁动作开销减小,锁区域的代码并不会加速,该区域依然需要串行访问。

volatile变量也会像普通变量那样从主存中拷贝到各个线程中去操作,区别在于它要求实现StoreLoad指令屏障(JVM未必一定使用这种指令来实现,之前说的4种指令屏障只要对应处理器支持,可以采用具体平台来优化,StoreStore,LoadStore,LoadLoad)。

综上,volatile第一个作用是,保证多线程中的共享变量始终是可见的(不保证volatile引用对象内部的非volatile属性是完全可见的)。

其次,对于volatile修饰的变量,必须一次性赋值。

volatile解决重排序问题

volatile赋值操作:

要求在对volatile变量进行读写时,前后的指令在某些情况下不允许进行重排序,不论编译时重排序还是处理器重排序,都不可以。

比如,对一个volatile变量赋值,那么该赋值之前的任何代码不允许和该赋值操作交换顺序。

int a=0;volatile boolean isAZero=true;

a=2;isAZero=false;

在另一个线程中:

if(!isAZero){System.out.println(1/a)}

如果重排序可能有除0的异常,但是如果ISAZero这个变量是volatile的话,那么就能保证1/a的时候,a已经不是0了。

注意:

①如果在之后有普通变量读写,那么可以与它交换顺序。

②该动作之前的指令相互间可以重排序,只是不能排的该volatile变量赋值操作后面。

读取volatile变量:

正好相反,在它后面的操作不允许与它交换位置,但它们之间可以重新排序,在它前面的普通变量的操作动作可以与它交换顺序。

普通变量读写可以重排序,如果不影响语义。两个volatile变量的读写彼此顺序不能重排。

举例:

①一个变量被赋值后,经过某些读写操作,再赋值给另一个变量,顺序不会变。

②try catch finally顺序不会变,不会存在try中语句没执行完,finally开始执行。

综上,volatile第二个作用,防止相关性代码重排序

final

JMM要求final域的初始化动作必须在构造方法return之前完成。一个对象创建和赋值是两个动作,对象创建需要经历

①分配空间

②属性初始化

这两个过程,普通的属性初始化的允许发生在构造方法return之后(指令重排序)。

大部分情况下,对象的使用都是在线程内部定义的,在单线程中是绝对可靠的,即在单线程中要求使用对象引用的时候,该对象已经被初始化好了。

但在此过程中,有另一个线程通过这个未初始化好的对象引用来访问相应属性,就可能出现问题。java中的final可以解决这类问题。

注意①:final解决的问题,是在构造方法返回之前确保属性的赋值完成,但是不保证构造方法中赋值语句的顺序,即不解决重排序的问题。

final修饰的数组或者集合类型,不保证其中的元素也是final类型。

注意②:volatile和final修饰符修饰变量都是有开销的,我们需要关注代码是否真的有并发问题,如果数据本身是只读数据,或者这些java对象本身就是线程所私有的局部变量,类似ThreadLocal,那么就没有必要使用这些修饰符了。

栈封闭

线程操作的数据都是私有的,不会与其他线程共享数据,即每个线程所访问的JVM区域是隔离的,那么这个系统就像一个单线程系统一样简单了。

通常在做Web开发时不需要自己去关注多线程的内在,因为web容器已经帮我们做好了,从前端请求就给业务分配好了数据,web容器自动给我们提供私有的request和response对象的处理,因为不会被其他线程占用,所以线程绝对安全。

但是当我们需要去并发的访问某些共享缓存的时候,当需要操作共享文件数据的时候,或者自定义线程去并发的做一些任务的时候,都需要考虑并发问题。

举例分析

在Spring中注入DAO层,DAO有一个StringBuilder变量,用来拼接SQL,会进行多次append,然后toString。有人会认为,这个StringBuilder可以给许多DAO的方法使用,以节约空间。

问题①:首先抛开并发问题,这并不能节约空间,Spring声称对象的时候默认是单例的,那么这个对象将拥有与这个DAO一样永久的生命周期占用内存。如果我们希望节约空间,那么我们希望它短命,在Young空间就干掉它,而不是让它共享。

问题②:其次,会有并发问题。虽然StringBuilder不是static的,但是由于它所在的DAO是单例的,由Spring控制生成,所以几乎等价于全局对象。那么它会在DAO的所有方法被访问时共享。即使这个类中只有一个方法,也会有并发问题(同一个方法是可以被多个线程共同访问的,因为它是代码段,程序运行时只需要从这里获取指令列表就可以了,如果所有代码都不能并发访问,那么多线程程序就会被完全串行化了)。

如果数据区域发生共享就有问题了,这里的StringBuilder就是共享数据,如果有两个线程在append,一个线程调用toString,那么得到的结果肯定不是我们想要的,如果一个线程toString之后再清空该StringBuilder,那么另一个线程再toString的时候就什么也得不到了。

方案①:StringBuffer也不能完全解决问题,StringBuffer只是把append方法加上了synchronized,只能保证每次append操作是线程安全的。

方案②:如果我们在外面再加一层锁来控制,可以解决问题,但是这样该DAO的所有方法到这里都是串行的,如果所有的DAO都是这样的,不考虑synchronized锁本身的开销,此时系统会像单线程系统一样运行,外部并发访问到达时,系统会很慢。如果访问过大,会有大量线程阻塞,以及线程持有的上下文无法释放,而且会越积越多。

大多数情况我们希望事情是乐观的,尽量细化锁的粒度,甚至靠近无锁化。但是对于大量循环调用锁的情况,尽量使用粗粒度锁。

关于栈封闭,除了使用局部变量外,还有一种方式就是使用ThreadLocal。这是一种变通的方式来达到栈封闭的目的。

ThreadLocal

set方法:ThreadLocal通过set方法初始化。set方法中首先根据Thread.currentThread得到当前线程,每个Thread中都有一个ThreadLocalMap变量,key就是该ThreadLocal,value就是set的值。

get方法:ThreadLocal通过get方法获取当前线程对应的属性。get方法中根据Thread.currentThread获得当前线程,然后得到当前线程Thread对应的ThreadLocalMap,然后通过该ThreadLocal作为key获取到对应的value。

ThreadLocal其实是一种用空间换并发安全性的做法,同一个属性在每个线程中都保留一份属于该线程的值,其他线程对属性的修改不会影响当前线程,即对当前线程不可见,达到了隔离的目的。

问题:

普遍认为线程结束后,ThreadLocal就应该回收了,如果线程真注销了结果正确,但是如果使用的是线程池,那么就不一定了。线程池中对线程的管理都是采用的线程复用的方法,其中的线程很难结束甚至永远不会结束——线程的持续时间不可预测,甚至于JVM声明周期一致,那么ThreadLocal变量生命周期也不可预测。

所以我们要知道ThreadLocal的源头在哪里,让set和remove有始有终。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: