独占
本文是《Concurrent Programming in Java™: Design Principles and Patterns, SecondEdition》第二章的读后总结,代码都来源于该书。
概述:
并发编程和串行编程的主要区别在于是否强制使用了某些策略(即确保数据完整性、一致性的技术方案,具体包括工具类、编程元语、设计模式),这些策略实际是面向对象技术的扩展。在串行编程中是否使用这些策略,不影响程序的正常运行,但是并发程序中为了解决并发问题,必须使用这些策略。
被多线程访问的对象,或是使用同步机制了,或是不变的,且不能是对象逃逸出其使用域。
编译器很难发现并发的问题(非语义层面的错误),这类问题,需要程序员靠自己的经验排查;这潜藏的提出了一个要求-程序员在升级单线程程序到多线程的的时候一定要特别注意,因为二者可能有很大的改动。
在安全的并发系统中,每个对象都努力保证自己的完整性。独占技术可以保证对象的不变性、瞬时状态的不一致性;
在软件工程中,对象的独占需要通过编程技术(锁,监视器)和设计模式(封装,完全对象)来保证。所有的这些实现方式依赖于下面的三个基本策略。
-
不变性:确保所有的方法不会同时修改对象,不变对象或者程序员自己保证ADHOC(文档约束)
-
同步:通过加锁机制来保证一个对象同一时间,只能被单个线程访问
-
限制:限制对象作用域,通过隐藏或者限制对象的使用权限,来结构性保证智能有一个线程使用该对象
上述三个策略的组合,可以解决安全、同步、活跃性、性能、语义层面的功能扩展(工具类)问题。
三种策略讨论:
不变性:
概述:
对象初始化后永远不会改变状态的对象叫做不变对象
不变对象在多线程情况下不会遇到多线程操作(操作分散到不同线程,同时访问不变对象)导致的状态冲突和不一致问题。
如果不变对象内部封装了对象,内部对象一定不会改变,如果提供对外的更新方法,更新方法返回的是新创建的对象(类似String)。
不变对象不能用于处理界面以及线程间交互的场景(数据不变导致的)
不变对象主要有两种表现形式:
-
没有数据的对象
class StatelessAddr{ public int add(int a,int b){return a+b;} }
-
包含数据的对象,但是数据形式是final类型。这样的类不会出现读-写冲突和写-写冲突。
class ImmutableAdder{ private final int offset; public ImmutableAdder(int a){offset = a;} public int addOffset(int b){return offset +b;} }
所有不变对象封装的属性都应该使用final关键子;同时不变对象在没有被初始化之前,不能被其他对象获得(如果是单例模式,建议使用饿汉式),即避免this指针泄漏问题。一定要确保对象被正确的初始化。
this指针泄露的场景:调用依赖于对象完全初始化的方法(自己的)、将this指针记录到能被外部对象访问的属性中(自己的),将this指针当参数传递到方法中(别人的,如构造函数)
应用:
-
抽象的数据类型(ADT):类似String,Integer……,这部分对象实现的时候需要重写equal方法,对外暴露的修改方法创建新的对象。
-
数据容器:类似配置文件,在程序开始的时候将相关配置就加载完成,之后这些属性不会变化(没有热加载功能)
-
共享对象:类似java的System类、固定数据库连接池,不变的对象可以被多个线程共享,而不用考虑线程的安全性。
同步
概述:
使用锁来解决底层的存储冲突和高层的不变约束冲突。
synchronized属性总结:
synchronized的两种表现形式:
void f(){synchronized(this){}}
synchronized void f(){}
-
每一个Object和他的子类都有一把锁,基本类型没有锁;
-
包含基础类型数据的数组有锁,但是数组中的基础类型不包含锁。
-
包含Object类型的数组被锁住,不会自动锁住数组中的每一个元素
-
成员变脸不能被标记为synchronized;synchronized只能在方法中被使用。
-
synchronized不属于方法签名的一部分,子类覆盖父类的时候,synchronized修饰不会被继承。因此接口中的方法不能被声明为synchronized,同样构造函数也不能声明为synchronized,但是在构造函数内部可以使用synchronized块。
-
子类和父类公用同一把锁,但是内部类的锁和外部类无关。
-
内部类的非静态方法可以使用他外部类的锁。
-
synchronized无公平性保证
-
synchronized是可重入的
-
synchronized的功能比原子操作更多,但是他可以用来实现原子操作。
-
synchronized作用于非静态方法使用的是this锁
-
synchronized作用域静态方法使用的是类对象锁(类似String.class)
-
使用this锁不住类中的静态属性,应该使用类锁。
-
子类包含父类的静态数据的时候,不要使用synchronized(getClass())这种操作,要明确锁的对象。
-
类锁的在类加载和卸载的时候被JVM使用,其他时候JVM都不会主动操作他的。
volatile属性总结:
volatitle可以确保原子性(部分)、可见性、顺序性。
volatitle读写操作等同于下面的代码
public class VFloat{
private float value;
final synchronized void set(float f){value = f;}
final synchronized float get(){return value;}
}
注意:
自己覆盖java. 类的时候要清楚父类的锁的机制*
应用:
-
完全同步对象
-
所有对外暴露的方法都是同步的。
-
成员变量不对外暴露,即没有封装问题。
-
所有方法都是有限的(没有无限循环,和无休止的递归),最终都会释放锁。
-
所有的成员变量在构造方法中已经初始化完成。
-
对象的状态空间是可控的,遵循不变的约束,即使出现了异常情况。
-
-
遍历
-
同步聚合操作:扩展对象,使用对象的this锁,锁住所有的操作。优化策略,调用其他方法的时候释放锁。
interface Procedure{ void apply(Object obj); } class ExpandableArrayWithApply extends ExpandableArray{ public ExpandableArrayWithApply(int cap){super(cap);} synchronized void applyToAll(Procedure p){ for(int i=0;i<size;i++){ p.apply(data[i]); } } }
-
同步索引操作:使用客户端锁,即实例锁,锁住所有的操作。
for(int i=0;true;++i){ Object obj = null; synchronized(v){ if(i<v.size()){ obj = v.get(i); }else{ break; } } System.out.println(obj); }
这个版本还是有问题,可能会调换集合中两个元素的位置。这样的话,使用下面的拷贝方案就是很好的选择
Object[] snapshot; synchronized(v){ snapshot = new Object[v.size()]; for(int i=0;i<snapshot.length;i++){ snapshot[i] = v.get(i); } } for(int i=0;snapshot.length;++i){ System.out.println(snapshot[i]); }
-
版本迭代方案:使用版本控制对象的版本,一旦版本变更,相关操作就失败或者重试(迭代器的快速失败机制)
class ExpandableArrayWithIterator extends ExpandableArray{ protected int version = 0; public ExpandableArrayWithIterator(int cap){super(cap);} public synchronized void removeLast() throws NoSuchElementException{ super.removeLast(); ++version; } public synchronized void add(Object x){ super.add(x); ++version; } public synchronized Iterator iterator(){ return EAIterator(); } protected class EAIterator implements Iterator{ protected final int currentVersion; protected int currentIndex = 0; EAIterator(){currentVersion = version;} public Object next(){ synchronized(ExpandableArrayWithIterator.this){ if(currentVersion != version){ throw new ConcurrentModificationException(); }else if(currentIndex == size){ throw new NoSuchElementException(); }else{ return data[currentIndex++]; } } } public boolean hasNext(){ synchronized(ExpandableArrayWithIterator.this){ return (currentIndex < size); } } public void remove(){} } }
迭代的时候使用代码
for(Iterator it = v.iterator();it.hasNext);){ try{ System.out.println(it.next()); }catch(NoSuchElementException ex){} catch(ConcurrentModificationException ex){} }
-
-
单例
-
单例模式除非十分耗性能,否则请使用饿汉式,这样会规避很多问题(例如:双重校验问题)。如果必要,可以使用饿汉式,且调用方法全部改成静态方法。
-
独占模式下的活跃性问题:
-
死锁
-
解决方案一:顺序化资源。这种方案就是将锁对象进行排序,按照固定的顺序获取锁。代码示例:
public void swapValue(Cell other){ if(other == this){ return; }else if(System.identityHashCode(this)<System.identityHashCode(other)){ this.doSwapValue(other); }else{ other.doSwapValue(this); } } protected synchronized void doSwapValue(Cell other){ long t = getValue(); long v = other.getValue(); setValue(v); other.setValue(t); }
顺序化锁不能解决级联调用的死锁场景(即A可能掉B的方法b,也能掉C的方法c,且b、c是否使用锁都不确定)
-
java的存储模型
概述:
java的线程模型主要围绕三个关注点,来解释多线程模型下,java程序的相关行为,分别是:
-
原子性:主要描述实例、静态变量、数组的简单读写行为,不包括方法的局部变量。
-
可见性:定义了什么情况下一个现场对成员变量的写操作,对另一个线程是可见的。
使用锁的情况下的可见性的保证的解释是:释放锁的时候吧所有线程使用的变量的存储数据刷新到主存中,或得锁的线程重新装载访问的成员变量的值。锁在高层提供了独占的保证,在底层解决了存储冲突问题(占用锁期间涉及的所有成员变量)。
并发编程和分布式编程是类似的。synchronized可以看作,他使的一个现场的方法可以发送/接受另一个现场的方法对数据的修改,从这个角度看,锁和发送消息只是语法层面的不一样。
volatile直接和主存打交道,每次写实时写入主存,每次读实时从主存读取数据。
父类实现Runable接口,子类调用在初始话的时候调用Thread(this).start这个时候锁被启动的线程占用,这个时候子类可能还没有完全初始化完成
还有一种可能性,在构造函数里面启动另一个线程,且把this指针传递进行,也可能是一个没有构造完成的对象。
-
顺序化:
一、重排序
-
编译器可以重新安排语句的执行顺序。
-
处理器可以改变及其指令的执行顺序。
-
存储系统(由于被缓存控制单元控制)也可以重新安排对数据的读写操作
限制
概述:
限制利用封装的技术,从结构层面确保同一时刻最多只有一个活动访问某个对象,这样可以确保对象的完整性(不使用锁)。
程序层面的实现方式主要是,定义类和方法建立防止泄漏的所有者区域,保证只有一个线程,或者某一个时候只有一个线程访问被限制的对象。
限制思依赖于编程语言提供的作用域、存取控制、和安全特征,以及语言层面的数据隐藏封装技术。
限制的本质是确保数据的唯一性,这个是语言层面无法支持的。
对象泄漏可能有四种场景
-
方法m中调用方法或者进行对象初始化的时候,把r作为参数。
-
在方法m中把r作为方法调用的返回值。
-
m方法中,在一些其他活动可以访问的成员变量中记录了r(最坏的情况,任何地方都可以访问static类变量)。
-
m释放了一个针对r的引用(上面的任何一种方法)。
解决泄漏有四种方案:
-
方法限制
-
线程限制
-
对象限制
-
组限制
方法限制
概述:
方法限制指,最多一次对外(尾调用)暴露本方法内部创建的对象。
实现:
-
尾调用(传递协议):方法创建的对象只在方法的尾部被暴露出去,如作为参数(包括构造参数,形参)、作为返回值。尾调用是最简单、有效的方式。确保同一时刻,对象只能最多被一个活动的方法执行。
class Plotter{ public void showNextPoint(){ Point p = new Point(); p.x = computeX(); p.y = computeY(); display(p); } protected void display(Point p){ //p只会被泄露到这 } }
该类型有个变种-会话,在入口出创建对象,对象被限制到本次调用中(该线程中)调用完成,入口处执行对象的清理工作。
class SessionBasedService{ public void service(){ OutputStream output = null; try{ output = new FileOutputStream("..."); doService(output); }catch(IoException e){ hanleIoFailure(); }finally{ try{ if(output != null){ output.close(); } }catch(IoException ignore){} } } void doService(OutputStream s) throws IoException{ s.write(...); //..... } }
-
替代协议:对外暴露方法内创建的对象的复制品,主要包括调用者拷贝、接受者拷贝、使用标量参数。或者指定调用规约,口头约定,调用方不修改对象。
public void showNextPoint2(){ Point p = new Point(); p.x = computeX(); p.y = computeY(); display(p); recordDistance(p); } 调用者拷贝:dispay(p);-->>> dispay(new Point(p.x,p.y)); 接受者拷贝:dippay(p);---->>>void dispay(Point p){Point loacalPoint = new Point(p.x,p.y)} 使用标量参数:dispay(p);改写成dipay(int x,int y)
线程限制
概述:
把对象现在在线程层面上,这个从进程空间中演变过来的概念。
实现:
-
线程私有变量:继承Thread类,子类封装相关的成员变量。
-
线程本地变量:ThreadLocal
优缺点:
-
【优点】把对象引用放在Thread对象内部(或者和她相关联),使得运行在同一个线程的方法可以共享这些引用(不必使用参数的形式进行调用传递),类似上下文信息(当然也可以用这个实现上下文)。
-
【缺点】隐藏了行为参数,使得排查问题变得困难类似,静态全局变量(修改静态全局字符串,忘记打包调用地方的代码了)
-
【优点】改动会影响所有相关的代码。
-
【缺点】所有受改动影响的代码如何协调
-
【优点】调用这些数据的代码不需要同步
-
【缺点】但是访问这些数据的待机和没有竞争是的同步方法付出的代价差不多,所以建议在对象需要共享且在多线程可竞争该对象的情况下,私用该技术才能够提高性能。
-
【缺点】增加了代码的耦合性,降低了重用性。
-
【优点】ThreadLocal可能是唯一不使用参数传递实现代码协调工作的方法。
对象限制
概述:
这种模型类似完全对象,外部对象和内部实际对象的关系是聚合关系(UML用菱形表示);所有对外暴露的方法使用同步;内部对象一般在外部对象的构造函数中初始化,外部对象不暴露内部对象的引用(内部对象自己也要注意不暴露自己的this指针)
应用:
-
适配器:Collections的同步集合包装启,类似“synchronizedList”
-
子类化:通过子类化实现多版本的控件管理方式,类似集合框架
组限制
概述:
该种方式多用于分布式系统中,类似“令牌”的概念;主要包括四个方面的设计,“获取”、“丢弃”、“放入”、“拿出”、“交换”;最简单的模型就是环路模型。
独占模式的优化
概述:
不合理的独占锁可能导致性能、活跃性的,具体表现如下:
-
大锁会导致性能问题,增加系统延时
-
太多的小锁会可能导致活跃性问题
-
使用同一把锁管理并发可能性很高的几个功能,可能导致不必要的竞争,影响性能。
-
长时间持有锁会导致性能和活跃性问题。
优化:
一、减少同步
概述:
解决同步的活跃性最好的方法就是减少同步。减少同步主要有三个适用场景:
-
存储
-
双重检查
-
开放调用
一、存取
存取操作一般都是性能的瓶颈(有的时候不是),如果想优化改点,主要有有个考量的角度:
-
合法性:这个性质考量的是程序状态空间的合法性,主要包括:原子性、不变性
-
陈旧性:这个性质考虑的是程序对数据新鲜度的要求,主要是:可见性(对数据的变动是否立即可见)
主要的程序设计中主要考量的是这两个角度的三种组合:
-
合法性不符合的解决方案:
-
同步所有的存取方法。
-
确保能够感知非法值,同时采取对应的行动。
-
设法省略存取方法。
-
-
合法性符合,但是陈旧性不符合解决方案:
-
可以使用volatile替代同步,但是volatile只能是引用可见,他管理的对象不确保可见性,比如数组中的元素的可见性无法保证,同时volatitle禁止了重排序,可能会影响性能。
-
-
合法性符合,陈旧性可以容忍:
-
可以直接去除同步。
-
如果允许的话,也可以把域声明为public.
-
二、双重检查:
如果一个非同步的成员变量(一次初始化,之后再也不会变化了),通过get方法获取(可能读取到非初始化的值),这个时候get方法不一定需要synchronized方法修饰,可以使用双重检查取代synchronized方法。
双重检查的核心思想,非同步环境下条件不满足的时候,在同步的环境下重新访问该成员变量,判断一下最新值,然后采取恰当的行动
注意点:
-
不要对对象或者数组的引用使用双重检查。
-
一个标致无法表示所有所有的成员变量初始化。
针对上述注意点的解决方案:
-
使用锁
-
饿汉式
-
完全同步的检查
补充:
还有一种单次检查的情况,这种情况在多线程环境下可能会多次初始化,只有多次初始化没有副作用,同时多线程竞争的情况很少的情况下,使用他是可行的。
三、开放性调用(非同步的发送消息)
如果多个操作间没有状态的依赖关系(如:存在共享变量),即某些操作是无状态的、可以使用开发性调用。
注意:
开发性调用需要文档化,否则可能出现,同步方法里面调用开发性调用的方法,导致开放性调用的方法变成同步方法。
二、分解类
概述:
如果一个类的相关属性和行为可以分解成独立,互不干扰的子部分,那么就值得使用细颗粒度的辅助对象来创建类(具体功能的实现体),主类代理这些辅助对象的相关操作。
具体方式包括:
-
分解类:将有耦合的功能、属性封装到一个类中,这些类分别使用单独的锁进行管理。
-
分解锁:和分解类的思想类似,只不过是吧子部分里面的锁提取到了主类里面,在子类里面划分方法的空间;标量的变量(类似int,string,boolean)可以使用CAS进行管理;针对链表类型的数据结构,在入口类完全同步化,和每个节点都同步化,是极端的策略,可以在两者间找到一个平衡点。有入队出队两把锁控制队列的相关策略。
只读适配器
概述:对外暴露的全是只读适配器包装的对象
写拷贝
-
内部写拷贝,内部对象暴露的外部的写操作,全部使用同步复制操作进行。
-
乐观更新:CAS
开放容器:
内部锁:现在了程序的扩展性,外部只管调用方法,不用管锁。
外部锁:外部自己管理锁,管理复杂,内部对象拥有很好的扩张性。
多个对象之前可能有死锁的场景的时候,可以把他们封闭到一个host里面,在操作前先获取host的锁。
锁工具
概述:synchronized有很多的缺点
-
如果A线程尝试获取锁,但是锁已经被B现场持有,A没办法回退,也没有超时机制,也没有中断机制。存在活跃性问题,可能出现死锁的情况。
-
没有办法实现高层次的锁协议,如重入性、读写锁、公平性。
-
没有同步的访问控制。
-
不能够跨方法创建-释放锁。
独占锁的实现策略
组件:
-
synchronized关键字
-
wait/notify关键字
-
boolean变量flag,指示是否已经被占用,true在用,false不在用。
概述:
要实现独占、非重入的锁,要考虑占有,释放的情况;占有的情况又具体分两种,有超时限制和无超时限制的;
释放操作很简单,synchronzied关键字里面将flag置false(不在用),释放操作是无副作用的。
占用的操作
-
无时间限制
-
首先判断当前线程的状态是否已经被中断了,中断抛出异常。
-
进入synchronized块。
-
使用while块判断flag是否为true,为wati等待;否则直接将flag改成false。(因为wait可能抛出interruptedException,所以用try-catch块包裹,内部处理调用notify,同时抛出对应的异常)
-
-
有时间限制
-
首先判断当前线程的状态是否已经被中断了,中断抛出异常。
-
进入synchronize块
-
使用if断锁是否空闲,空闲直接获取锁(flag置true,返回true)
-
判断传入的时间值,是否小于0,小于0返回false;结束锁的获取。
-
否则使用一个死循环,使用传入的时间,当前时间,作为标量做一个计时器,阻塞等待超时的结果
-
代码
public class Mutex implements Sync {
/** The lock status **/
protected boolean inuse_ = false;
public void acquire() throws InterruptedException {
if (Thread.interrupted()) throw new InterruptedException();
synchronized(this) {
try {
while (inuse_) wait();
inuse_ = true;
}
catch (InterruptedException ex) {
notify();
throw ex;
}
}
}
public synchronized void release() {
inuse_ = false;
notify();
}
public boolean attempt(long msecs) throws InterruptedException {
if (Thread.interrupted()) throw new InterruptedException();
synchronized(this) {
if (!inuse_) {
inuse_ = true;
return true;
}
else if (msecs <= 0)
return false;
else {
long waitTime = msecs;
long start = System.currentTimeMillis();
try {
for (;;) {
wait(waitTime);
if (!inuse_) {
inuse_ = true;
return true;
}
else {
waitTime = msecs - (System.currentTimeMillis() - start);
if (waitTime <= 0)
return false;
}
}
}
catch (InterruptedException ex) {
notify();
throw ex;
}
}
}
}
}