JAVA并发编程之对象的共享


  • 这一章看的东西乍看之下感觉都是显而易见但是深究起来又别有洞天,总能有所得的。这章主要讲的是并发编程下对象共享时会出现的一些问题,而我主要记录一下我觉得有所得内容或有启发的例子。

1.(内存)可见性


  • 在java的内存模型中,基于效率的原因,每个线程会从主内存中拷贝一份变量的副本到的工作内存中使用。这其中就有一些问题
    • 如果线程1拷贝了一个变量A到自己的工作内存中
    • 在线程1还未操作变量A的副本之前,线程2就修改了主内存中变量A的值,
    • 而由于线程1的工作内存中已经有了变量A的副本,它会直接使用变量A的旧值进行操作,使得变量A的新值对线程1并不是立刻可见的

所以可见性的含义就是指:如果一个线程更新了一个共享的变量,在这一刻起其它线程读取使用的应该就是新的变量值,新的变量值对于其它线程来说应该是立刻可见的
举几个例子说一下什么是不正确的:

  • 比如一个线程修改了状态,而另一个线程要读取这个状态返回的仍是之前的状态。
  • 第二种我以前还真没想到过,一个对象正在被一个线程实例化,而另一个线程读取这个的对象,但是读取到的却是未完全初始化好的对象;
  • 第三种是指令重排导致的,jvm会帮我们把指令重新排序后以提高运行效率,但是重排之后可能会得到错误的结果。比如下面这段代码,如果main里面的语句重排后按312运行,那么输出的值可能是0,因为执行输出语句时,number的赋值语句可能还没执行或正在执行,这就要看哪边的线程跑的快了。
public class CodeRearrangeDemo extends Thread {
	static boolean ready = false;
	static int number = 0;

	public void run() {
		while (!ready)
			Thread.yield();
		System.out.println(number);
	}

	public static void main(String[] args) {
		new CodeRearrangeDemo().start();// 1
		number = 42;// 2
		ready = true;// 3
	}
}

这些问题防不胜防,你不可能推断出所有出错的原因,但是你却可以简单的避免他们,只要数据在多线程之间共享,那就要使用正确的同步

2.失效数据


当一个线程读取到一个状态的时候,这个状态可能是失效了的状态。说白了就是该状态的修改与读取没有同步的问题,但是举得例子很有意思。

  • 线程可能会读取两次状态但是,一次获得的是正确的,另一次获得是错误的。比如如下代码,num是共享数据,num正在被其它某个线程由3修改为5了,而同时另一个线程在执行如下语句时可能第一次读到的num是3(失效的),而第二次读num时就是5了,最终num值变成了8,一个完全错误的值。
num=num+num;
  • 比如对象的引用失效了,而线程却正好获取到了这个失效了的引用,就有可能导致报出异常或者更严重的安全问题。

3.非原子的64位操作


线程在没有同步的情况下读取了共享数据,可能会得到失效值,但这个值至少是某个线程设置的值,而不是一个随机我们就称这是最低的安全性(out-of-thin-airsafety)。
对于绝大多数变量,java内存模型是符合最低的安全性的,但是有个例外。对于非volatile类型的64位数值变量(double和long),jvm允许在读取操作或写入操作时分解为两个32位的操作指令,也就是先操作一个32位的内存,再操作另外一个32位的内存。这就有很可能在读取时,读取某个值的高32位和另一个值的低32位组成一个完全错误的数据。

4.volatile 关键字


volatile关键字具有有序性和可见性,不具备原子性。它也没有锁机制,所以是相对于synchronized关键字更轻量级的同步机制。相对的不具备原子性说明它不是一个完整的同步方式,volatile的同步具有一定局限性。(详细说明

5.发布与逸出


  • 发布(Publish)

即发布对象,把对象的引用传递给当前作用域之外的代码,使对象被它们使用。

一开始文绉绉的,看着发愣,不明所以。说起来很简单,就是创建对象并赋给一个变量,这就算是发布了,如果这个变量是静态公有的或者能被多线程共享,那就是相当于将该对象发布 给这些线程使用了。
例:

public class PublishDemo {

	public static Set<EntityInfo> infos;
	
	public PublishDemo(){
		infos=new HashSet<EntityInfo>();
	}
}

PublishDemo构造函数同过静态变量infos发布了set集合对象。
但这样产生一个问题,infos集合里面存储的是EntityInfo对象的引用,那么我们infos发布出去,相当于间接把EntityInfo对象也发布出去了。
间接发布的手段也非常多,只要能把引用传递出去,都算是发布了,看了几个案例感觉没有什么特别好讲的也就不复述了。

  • 逸出(Escape)

发布了本不该发布的对象,或错误的发布

逸出也是发布,区别在于:发布在于你想发布,逸出在于你不想发布的对象却也被发布了。或者在时间维度上说,在某一时刻你还没打算发布的对象也被发布出去,这也算逸出。
发布和逸出怎么分辨?这也是基于正确性2
发布了本不该发布的对象,一般都是不正确的间接发布导致的,我们举个栗子:

public class EscapeDemo {

	private List<String> infos = new ArrayList<String>();

	public void add(String val) {
		infos.add(val);
	}

	public synchronized String removeFir() {
		if (infos.size() > 0) {
			return infos.remove(0);
		}
		return null;
	}

	public List<String> get() {
		return infos;
	}
}

我们封装了一个集合,我们的目的是要确保对这个集合的安全操作。但是却多了get方法,画蛇添足的把infos 集合发布出了EscapeDemo 对象作用域之外的地方,这样导致了infos 对象可以被外部代码随意的操作?那么会不会出现问题呢?如果你无法知道infos 对象被发布的范围或被怎样操作,那就要按照最坏的打算去思考,它一定会在某一时刻产生无法预估的错误。这就是逸出,你发布了它,但是你本没有考虑到发布了它的结果。

错误的发布,这个栗子没有吃过,很有意思,先看栗子:

public class EscapeDemo {

	public EscapeDemo(EventSource source){
		source.registerListener(
				new EventListener(
						public void onEvent(Event e){
							doSomeThing(e);
						}
				));
	}
}

我们在构造函数中创建了一个监听线程,但是有一个问题EscapeDemo的this被隐式的传递到EventListenerList实例里面了,而且是EscapeDemo还没创建好的情况下,EventListener就可以被启动并且有可能通过this调用EscapeDemo对象。也就是说在监听代码里面发布了一个不完全的EscapeDemo对象,这就有可能导致不可预见的错误。这种不正确的构造也是逸出。
正确的做法:不要在构造函数中立即启动监听线程,而是先保存这个对象的引用,等构造完成后再通过其它方式启动它。
如下,通过工厂模式,私有化构造函数,通过newInstance方法发布EscapeDemo 对象。

public class EscapeDemo {

	private final EventListener listener = null;

	private EscapeDemo(){
		listener=	new EventListener(
						public void onEvent(Event e){
							doSomeThing(e);
						});
	}

	public static newInstance(EventSource source) {
		EscapeDemo demo = new EscapeDemo();
		source.registerListener(demo.listener);
		return demo;
	}
}

另外并不是只有线程放置在构造函数中会导致逸出,如果在构造函数中调用一个可改写的实例方法(不是终结方法,也不是私有方法),同样也会导致this引用在构造过程中逸出。
这个我看了其它博客的文章,我理解大致意思应该是这样的,在实例方法中是可以通过super关键字使用父类方法的引用的,如果调用了可改写的实例方法,你并不会知道这个呗改写方法究竟会如何操作这个还未构造完全的父类,从而导致不可预测的安全问题。

6 线程封闭


为了避免同步问题,最简单的方法就是不共享数据,让变量只在一个线程内可见使用,或者同一时段只有一个线程对该变量可见处理。这种技术就称为线程封闭。

最常见的线程封闭的例子就是局部变量,如下的value变量,生命周期只在getPowerNum函数内,并不会出现共享的情况。

public class PublishDemo {

	public Integer getPowerNum(Integer power){
		Integer value=1;
		for(Integer i=0;i<power;i++){
			value=value*2;
		return value;	
	}
}

但我们这章说的是对象的共享,共享 的对象能否也能使用线程封闭技术来实现线程安全呢?毕竟线程封闭技术是实现线程安全最简单的方式之一。
书中提供了两种设计思路我觉得很有参考的意义。

  1. 在业务上虽然这个对象是共享 的,但是在技术处理时,我们只允许一个线程来访问处理它,让对象封闭在这个线程里。而其它线程需要使用的时候,通知该线程来处理即可。
    • 比如Swing中,Swing的可视化组件和数据模型都不杀线程安全的,但是Swing通过将它们封闭到 事件分发线程 中来实现线程安全。但是需要注意要想正确的Swing,其它线程中就不能够访问这些对象,否则就有可能产生并发问题。
  2. 第二种就是同一时段只有一个线程对该变量可见处理。
    • 比如JDBC(Java Database Connectivity)中的Connection 对象。我们通常是从连接池中获取一个Connection 对象,用完后再把连接返回给连接池,而在该Connection 对象返回之前,该对象是不会再被分配出去的。这种实现使得Connection 对象虽然是可共享的,但是同一时段内只会在一个线程内可见与使用。间接的把Connection 对象封闭在线程中。

java语言的机制并没有什么强制手段把对象封闭在线程中,线程封闭必须通过程序设计来实现,这也使得在代码设计时要格外注意防止逸出问题。

Ad-hoc线程封闭

就是完全靠实现者代码控制的线程封闭。Ad-hoc线程封闭非常脆弱,没有任何一种语言特性能将对象封闭到目标线程上。

栈封闭

就是前面所说的局部变量,局部变量的固有属性之一就是封闭在线程之内。局部变量的引用值是存储在栈内的局部变量表之中,其它线程无法访问到。

ThreadLocal封闭

这是java核心包为我们提供的一个实现线程封闭的类。示例如下

public class PublishDemo {

	public static ThreadLocal<EntityInfo> entity;

	public EntityInfo getEntityInfo() {
		return entity.get();
	}
}

ThreadLocal类的设计思路源于map集合。ThreadLocal内部是有个类Map的结构来存储数据(ThreadLocalMap<Thread,T>),它使用线程为键值,为每一个线程都维护一个独立的EntityInfo对象副本,并且ThreadLocal提供了get、set、remove等方法来维护该副本对象。这使得看上去虽然entity对象是共享的变量,但是里面的每个EntityInfo只会被自己所属的线程访问,互不干扰。当线程终止后,线程对应的值也会被垃圾回收。
一个使用的例子是EJB的调用期间,J2EE容器会分配事务上下文,与某个执行的线程关联起来。
ThreadLocal变量类似于全局变量,它降级了代码的可重用性,并且在类之间引入了隐含的耦合性,因此使用时要格外的小心。

7 不变性


满足同步需求的另一种方法是不可变对象(Immutable Object)。由于对象的状态不可变了,那么共享的时候也不会出现之前所说的问题。不可变对象很简单,它只有一种状态,并且是在构造函数时确定的。

不可变对象一定是线程安全的

  • 例:不可变对象
public final class ThreeStooges {
    private final Set<String> stooges = null;

    public ThreeStooges() {
   	stooges = new HashSet<String>();
        stooges.add("Moe");
        stooges.add("Larry");
        stooges.add("Curly");
    }
    public boolean isStooge(String name) {
        return stooges.contains(name);
    }
}

在java语言规范与java内存模型中没有给出不可变性的正式定义,书中给出了如下三个条件,满足这三个条件的对象才是不可变对象:

  • 对象创建以后其状态就不能修改
  • 对象的所有域都是final类型
  • 对象是正确创建的(在创建对象期间,this引用没有逸出)

一开始我看着是一脸懵逼,想到头炸也不明所以为什么是这三条,看了好多资料也才慢慢有些明白了,下面我一条条说明这三个条件的含义。

  • 第一条很简单,对象创建之后内部的状态自然就不可改变了,如果改变了怎么还叫不可变对象。但它还隐藏着另一个说法,如果对象里的状态需要改变,那一定是生成了另一个对象而不是改变之前的对象。
  • 第二条是我最迷惑的点,为什么都得是final域?如上stooges 状态不加final有什么问题么?首先类加final修饰是为了不让该类被继续继承重写,其次类内的状态都加上final修饰是为了确保初始化过程的安全性。简单来说就是加了final域修饰之后,final关键字的机制能保证构造函数执行完成了以后才会把对象的引用发布出去。否则线程访问isStooge方法时,stooges变量的引用值还是null(参考)。但是上面的代码可能就有一些问题,三个put语句并不具备安全性,就有可能导致访问时,stooges并不都具有这个值。除了保证安全性,还有就是通过将域声明为final类型,也相当于告诉维护人员这些域是不会变化的及良好的编程习惯。另外使用final域声明的开销并不会很大,可以忽略不计。
  • 对象是正确创建的上面也说明了,就是防止this的逸出,前面的由关于逸出的说明,就不复述了。

当然如果如果创建ThreeStooges 对象时用volatile关键字,可以确保绝对安全,也不需要第二点了,但是谁又能保证创建该对象时外部代码一定会使用volatile关键字呢?

Final域

fianl类型的域是不能修改的,但是如果final域所引用的对象是可变的,那么这些被引用的对象是可以修改的。
在JMM中,final域能确保初始化过程的安全性。原因如下:

对于final域,编译器和处理器要遵守两个重排序规则

  • 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  • 初次读入一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

1.写final域的重排序规则

  • JMM禁止编译器把final域的写重排序到构造函数以外
  • 编译器会在 final域的写 之后,构造函数return之前,插入一个store屏障。这个屏障禁止处理器把 final域的写 重排序到对象引用之前

2.读final域的重排序规则

  • 在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作。编译器会在读final域操作的之前插入一个Load屏障
  • 初次读对象与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,也不会重排序这两个操作。但有少数处理器允许对存在间接关系的操作做重排序,这个规则就是专门用来针对这种处理器的。
8 安全发布

我们知道,由于存在可见性问题,不安全的发布会导致线程看到的对象处于不一致的状态。即便如上对象的构造函数正确的构建了不变性条件(存疑,构建了不变性条件不就够了吗?)。这种不正确的发布导致其他线程看到尚未创建完成的对象。
我们先来看一下哪些错误发布的情况
例子:

public class Holder {
	private int n;

	public Holder(int n) {
		this.n = n;
	}
	
	public void assertSanity(){
		if(n!=n){
			throw new AssertionError("错误发布");
		}
	}
}

错误的发布:

public Holder holder;
public initialize(){
	holder=new Holder(4);
}
不正确的发布:正确的对象被破坏

你不指望一个尚未创建完全的对象拥有完整性,如上的例子,由于没有确保可见性,除了发布线程外,其它线程可能看到的holder对象可能是一个失效值(空引用或之前的旧值),或者更危险的是holder对象的引用是最新值,里面的状态(n变量还未赋值)却是失效的。上面的Holder称为“未被正确发布”。

不可变对象与初始化安全

不可变对象是非常重要的对象,因此java内存模型中提供了一种特殊的初始化安全性保证。满足了不可变条件的所有需求(状态不可修改,域都是final修饰及正确的构造),那么即使发布不可变对象时没有使用同步,所有线程也仍可以安全的不加同步的访问该对象。这种保证将被延伸到正确创建对象中的所有final域中。没有额外同步的情况下,仍可以安全的访问这些final类型的域。但是如果final类型的域的引用指向的是可变对象,在访问这些可变对象的状态时,依然要使用同步。

安全发布的常用模式

可变对象通过安全的方式来发布,通常意味着发布与使用该对象时必须使用同步。现在先重点介绍一下如何确保线程在使用对象时对象处于安全发布的状态。
要安全发布一个对象,对象的引用和状态必须同时对其它线程可见,一个正确构造的对象可以通过以下方式来发布:

  • 在静态初始化函数中初始化一个对象的引用。
  • 将对象引用保存到volatile类型的域或AtomicReferance对象中。
  • 将对象引用保存到某个正确构造对象的final类型的域中。
  • 将对象引用保存到一个由锁保护的域中。

我们举几个例子说明一下。最后一条,我们可以把对象放入到线程安全容器内部发布,例如Vector或synchronizedList等安全集合。由于它们是线程安全的,所以足以保证对象被正确的访问。还有一种是类库中的其他数据传递机制(例如Future和Exchanger)同样能实现安全发布,后面在介绍这些机制时会讨论他们的安全发布功能,先知道一下即可。

事实不可变对象

如果一个对象你确定他在发布之后不会被修改,那么对于其它在没有额外同步的情况下安全地访问这些对象的线程来说,安全发布是足够的。
我们前面讲过了不可变对象,那是在代码技术层面上控制的。如果一个对象技术上可变,但是在业务上来说发布了之后就不会再改变了,那么把这种对象称为事实不可变对象(Effectively Immutable Object)。这些对象不需要满足不可变性的严格定义。通过事实不可变对象,不仅简化开发还能减少同步而提高性能。

可变对象

安全发布只能确保可变对象发布时状态的可见性,对于每次对象访问仍需要通过同步来确保后续修改操作的可见性。要安全的共享可变对象,对象就必须被安全发布,并且是线程安全的或由某个锁保护起来。


以上我们讲了安全发布,我们可以得知对象的发布需求取决于他的可变性:
  • 不可变对象可以通过任意机制发布。
  • 事实不可变对象可以通过安全方式发布。
  • 可变对象必须通过安全方式发布,并且是线程安全的或由某个锁保护起来。
安全地共享

当发布一个对象时,必须明确的说明一个对象的访问方式。确定对象访问的“既定规则”,这有利于避免许多并发错误。
在并发程序中使用和共享对象,可以使用一些实用的策略:

  • 线程封闭:对象只能由一个线程拥有,可见及修改。
  • 只读共享:没有额外同步下,共享的只读对象可以由多个线程并发访问,但是任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
  • 线程安全共享:线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。
  • 保护对象:被保护的对象只能通过持有特定锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。

这一章大抵是这样了,还是有点囫囵吞枣的感觉,有一些地方不太理解,有些地方混乱,感觉前后矛盾。 希望后面的学习之后,前面的东西能茅塞顿开。GO!GO!GO!

猜你喜欢

转载自blog.csdn.net/u014296316/article/details/86320855