考虑并发问题的前提:
- 多线程
- 存在可读可变的共享变量
- 有多个线程读取这些共享变量的需求
其核心问题在于共享变量状态不一致,不一致的基本表现是信息的失效,根本原因是https://www.cnblogs.com/dolphin0520/p/3920373.html
比如我们要让一个变量自增1.
紧凑写法是 x++;
实际上这个自增语句被解释为
temp=x; ①
temp=temp+1; ②
x=temp; ③
分析来说,①号语句的问题在于:这个x变量是否已经失效或即将失效?即将失效的原因很好理解:在②语句执行前可能有其他线程改变了x,已经失效该如何理解呢?就是说,①语句读出的x甚至无法保证是上一个更新的值-----而可能是一个无效值,比如0或负无穷。失效尚且还存在一些可能性上的意义,而无效数据却是完全没有意义的。③语句存在的问题是,改变x的值后,其他线程可能已经在使用失效的数据(正如①语句分析)
这里要引入一个概念:内存可见性
所谓内存可见性,即我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态, 而且希望确保一个线程修改了对象状态后,其他线程能够看到发生的状态变化。
乍一看好像前后意思一样,其实前后两句完全是不同的情况,其原因在于我们所依赖的编译器、处理器很有可能有自己的一套优化算法->这些算法提高了运行效率,但是不可避免地导致指令重排序,这种重排序不受我们控制,所以永远不能相信一个读数据的线程能正确获得刚刚结束的写线程更新的正确值(即使这个读线程紧跟着写线程执行,从直觉上来说,这本不可能存在问题),一种可能的情况是:出于运行时的某种全局的调度算法考量,处理器可能将写线程的值暂时存在一个特殊的地方,不能被其他指令访问到,又或者根本不执行写线程-----单纯地往后推移。
甚至还有这么一种特殊情况:学过cpu的都知道,long和double类型往往是存放在高位和低位组合起来的一个数,那么由于读取高低位不是原子性的操作,就可能导致不一致。比如读了高位,低位却被修改了。
如果把线程的不安全性完全推给JAVA语言或者CPU算法的编写者,未免有失偏颇-----重排序或者调度优化能帮助我们利用多处理器的强大性能,给予充分自由度的同时,安全问题随之而来。
通过上述分析我们可以进一步抽象出同步问题的表现:依赖于访问一个或多个共享变量来正确完成任务。
为了保证线程间正确访问共享变量,有以下解决方法:
- 使用java.util.concurrent.atomic包中封装好的原子变量类,这种原子类保证对类实例的读取、更改等操作是线程安全地。许多情况下,使用一些原子变量类来创建我们的共享变量是足够的。然而,一旦共享变量间的关系并非完全独立,问题就产生了。考虑下面这种情况
private final AtomicVal serving_people;//服务的总顾客人数 private final AtomicVal serving_fat; //服务的胖子人数 public void service(){ if(serving_people.get() > 5){ if(...) ... } if(serving_fat.get() > 2){ serving_fat.decrese(); //1 serving_people.decrese(); //2 } }
假设我们在开一家自助餐馆,一旦肥胖的顾客多于2,我们就会请其中一位肥胖顾客离席。由于声明时使用了原子变量类,这里对肥胖顾客和总顾客单独操作都是没问题的,可惜其问题在于胖子人数是总顾客人数的子概念,他们之间存在联系--->请离胖子必然导致总顾客人数也要减少1。现在假设当前线程运行完语句1,还没进入语句2,另一个线程却已经在拿更新好的胖子人数和理应过时的总顾客人数去进行判断,显然不一致问题就出错了。的确,语句1执行结束前别的线程都没法访问serving_fat,可是却能先访问serving_people,一旦语句1执行完还没进入语句2,别的线程可不会傻傻地等当前线程语句②执行完毕。所以说原子变量类只能解决一些简单情形下的同步问题,更实用的做法是使用同步锁住复合操作代码块。
-
同步。使用同步的一个简单出发点是原子性,我们希望编译器和处理器不要乱动我们的执行顺序,因此用synchronized(lock){}包住我们的代码块。这下,我们的代码块是不可分割的(也就具有了原子性)。为了不让别的线程提交,我们还要给代码块上锁,也就是lock参数,一般都用类自身this作为锁,进入同步代码块时线程要获取锁,离开时释放锁。没有锁就只能被拒之门外等待。所以理所当然地,同一时间只能有一个线程进入同步代码块。而且注意,同一个类内线程进入某个函数获取锁后同样会锁住其他所有函数内的同步代码块,因为----一个类只有一把锁,而同步的本质在于保护类的共享变量而非仅仅一个代码块。
对函数整体直接添加synchronized大部分情况下不好,因为一般来说,有的耗时操作并不需要锁住(比如根据函数的参数申请网络资源、进行复杂计算),这时候在函数内部分段进行同步会更好,当然分段锁会增加开销,所以需要我们在安全性有保障的基础上权衡,获得一个良好的性能。
前面说到线程去获取锁,如果线程在一个同步代码块内部又进入另一个代码同步块会发生什么?答案是重入,也就是说,线程已经持有锁了, 不需要再去获得一把就能进入,但是JVM会记下锁的持有者,并且把计数器加1.当线程一个个退出的时候,计数器依次减1,最后为0时释放锁。比较常见的场景在于从子类同步代码块重入父类同步代码块,如果没有重入机制,父类的锁早就被线程申请占有,却无法获得新的一把锁,会形成死锁。
同步的引入给我们高并发编程带来了安全,且并没有什么安全上的副作用(性能上当然有副作用),可惜,为了使最终程序的安全性完备,我们必须全面地思考选择用同步上锁的代码块,这就显得有些繁琐。同步本身是好的,可是要想假设使用同步的情况下代码运行完全正确,还是带来了编程上的复杂度。
同步显然不仅仅是为了提供互斥----还有内存可见性的保证,往往这一点容易被人忽略。 -
volatile关键字修饰的变量。volatile是轻量级的同步变量,他的开销很小,而且能保证内存可见性,也就是说,这种变量保证每次从内存读取到的一定是最新有效的值,但是使用限制也很多,他不能保证互斥,所以多于一个线程进行写操作就不适合volatile(读是可以的)。而内存可见性加上互斥才是完整的同步问题,所以一般只用来做某个操作完成、发生中断或者状态的标志。如果非要用,其实原子变量更好。
现在我们应该知道安全优先级是这样的:同步->原子变量->volatile变量。当然性能表现可能恰好相反。
使用共享数据就会导致线程不安全,能不能不共享数据?
- 只使用局部变量,这是Java语言提供的支持。
- 将对象封闭在一个线程中。Java核心库提供了一些机制来帮助维持线程封闭性,如ThreadLocal类。
线程封闭就是这种概念:只在单线程内访问变量,因此没有同步问题
要想把对象封闭在线程内,我们首先要明确防止函数的返回值使这个对象逸出;比如直接返回对象或者返回能访问该对象的其他对象。
ThreadLocal
使用ThreadLocal的动机:
如connection、session这种性质的变量,我们不希望每次用的时候去创建一个connection,用完就close,这显然太慢了。也不希望单单放一个全局的connection给不同线程调用,这可能导致混乱。最好的方法是各用各的----你有你的连接/会话,我有我的,互不影响。
ThreadLocal机制在一个Thread内存放一个对应变量,不同Thread之间自然不能互相访问。
这种机制其实并没有共享变量,而是每个线程都有一个独立的变量,用起来像是共享的而已。
不变性
如果共享变量一经声明就不会改变状态,那么就不需要担心同步问题。
Final域
final关键字可以视为C++中const的一种受限版本,final能确保初始化过程的安全性。因为final只保证引用不变,可是引用指向的对象是可以改变的。为了实现不变性,我们可以把引用的对象类中大部分变量声明为final,这同样能简化我们的状态判断,甚至全部变量声明为final,那么这个final变量就是完全不变的----既改不了引用,也没法通过引用改变类内部的状态变量。
Volatile实现不变域
关于这个内容感觉书上说的太过特例了。正常的业务逻辑应该是不能用Volatile+不变域实现线程安全,因为Volatile并没法保证原子性。(还是用同步锁比较合适,也好理解。)
将对象的引用和对象内部成员都声明为final的确很有效,但是不够灵活,万一我们需要对这个对象进行改变呢?
比如前文说过的开餐馆,我们可以把serving_people和serving_fat放到一个serving_manager类里面,并都声明为final,这时候我们想要更新服务人数的信息,除了重新创建一个引用进行替换别无他法,可是如果将引用声明为final,又没法更新/改变这个引用,不声明为final又没法保证安全。怎么办呢?.
我们记得volatile是保证内存可见性的-----在别的线程读入前,保证写操作已完成并正确写入内存。这下好办了,将serving_manager的引用声明为volatile,线程依然不能直接改变manager里面的final变量,想要改变必须创建一个新的manager替换原来的引用,而volatile又保证写操作可见。这下我们就解决了一致性问题。由于对manager的创建要同时对两个final域初始化,我们不用担心serving_people和serving_fat不一致,但是多线程写操作的问题仍然存在-----也就是说,看到的依然可能是失效但状态一致的引用。(那这种做法还有啥用呢,除非根本不关心信息是否失效,只关心信息逻辑是否一致)
总结来说就是,在一个volatile引用指向的类里面声明多个有逻辑关联的final变量(或者干脆就是你想安全访问的变量),更改变量的方式只能通过创建一个新的类实例(引用)来代替原实例。
除去对一个变量进行操作时产生的同步问题,变量的创建过程可能导致不安全发布的问题。即其他线程拿到一个还没初始化完毕的变量。
为了安全地发布,我们可以给初始化语句上锁,或者该变量是不可变对象。不可变对象上面说过了,上锁可能有开销问题。除此以外,还可以把对象放到静态域中:比如声明为static,或者在static函数内初始化。还可以把对象作为一个volatile或者atomic类型或final对象的成员(就像上面实现不变域那样)。
但是必须注意到,static只能保证安全发布,并不能保证解决同步问题。
总结来说,要想确保一个对象单独使用起来是完全线程安全的,只能通过final域修饰。其他方法或多或少都会有不一致的问题存在。有时候我们不需要太在乎一致性,因为即使不一致也不会造成业务逻辑的错误。如果非要强一致性,只能将并发程序退化成单线程运行,这样效率就不太好了。
对象的组合