《Java并发编程实践》二(3):共享对象

第2章介绍了如何通过原子操作来保护对象状态,第3章介绍如何在多线程之间共享和发布对象。

多个线程共享一个对象涉及两个问题,第一个问题是:多个线程并发地访问对象而不发生错误;第二个问题是:当一个线程修改了对象状态,其他线程能够及时看到这个修改。第一个问题上一章已经给出解决思路,而第二个问题也被称为“可见性”问题,本章要重点讨论。

可见性

对于单线程的应用来说,如果一个变量被修改,那么后续对这个变量的读操作,读到的一定是修改后的值。如果读写发生在不同的线程,那情况可就不一定了。下面看一段示例代码,主线程写入一个值,另外一个线程读取这个值并打印:

public class NoVisibility {
    private static boolean ready;
    private static int number;
    private static class ReaderThread extends Thread {
        public void run() {
            while (!ready) {}
            System.out.println(number);
        }
    }
    public static void main(String[] args) throws InterruptedException {
        new ReaderThread().start();
        Thread.sleep(10);
        number = 42;
        ready = true;
    }
}

这段代码在本机机器上运行结果是什么都没打印出来,而且程序也不会主动退出。

理论上,这里有可能出现三种结果:

  1. 程序正常退出,并且打印正确结果42;
  2. 程序不退出,也不打印(本人遇到的情况);
  3. 程序正常退出,打印错误的结果0;

第二种结果发生的原因是,主线程写入的ready变量,ReaderThread线程可能读不到;第三种结果发生的原因,是主线程的代码虽然先写入number再写入ready,但是编译器优化可能将操作重排,即便编译器没有重排,ReaderThread线程也可能先读到ready再读到number。

可见性问题可归纳为:**如果没有线程同步机制,一个线程修改的状态,对另外一个线程来说,其可见性是不可预测的。**换句话说,没有同步机制的保护,线程可能读到过期的,甚至完全错误的状态值。

锁和可见性

锁不仅提供了原子性,也保证了可见性:线程A在释放锁之前所见的状态,线程B在获取到同一个锁时全部可见

下面这段代码,value字段,作为java的int类型,它的读写是原子的,但仍然不是线程安全的,因为存在可见性问题。

@NotThreadSafe
public class MutableInteger {
	private int value;
	public int get() { return value; }
	public void set(int value) { this.value = value; }
}

加上synchronized就可以解决该问题,依据锁的可见性规则,set&write都要加上:

public class SynchronizedInteger {
	@GuardedBy("this") private int value;
	
	public synchronized int get() { return value; }
	public synchronized void set(int value) { this.value = value; }
}

因此,锁为状态提供了两种保证,一是操作的原子性,二是状态的可见性,降低了编写线程安全程序的代价。

volatile

volatile关键字可以保证变量的可见性,是一种轻量级的同步方案。volatile关键字意味着,对变量的修改应当立即对其他线程可见,是对编译器和jvm的一个提示:围绕该变量的操作不能重排,变量值不能缓存在寄存器或CPU缓存中。

volatile对可见性的影响不仅如此,线程A在写入volatile变量可见的状态,线程B在读取该变量后全部可见。因此可将volatile的写,类比为离开synchronized代码块,将volatile的读,类比为进入离开synchronized代码块。

不过与锁不一样的是,volatile不保证原子性,所以加在64位的long&double类型上,可能导致错误的结果。
而且volatile的可见性规则,边界非常模糊,难以在代码中驾驭。因此在实际项目中,并不推荐广泛使用volatile,仅在满足以下场景时使用:

  • 对变量的修改并不依赖它之前的值(除非只有一个线程会修改它)
  • 该变量与其他状态字段相互独立,没有约束关系;
  • 访问该变量时,没有其他需要加锁的原因;

最常见的使用volatile场景,是修饰状态标记,比如线程退出指示器:

public Task extends Runnable
{
	volatile boolean asleep;
	
	public void run() {
		while (!asleep)
			countSomeSheep();
	}
}

确实有些高性能的框架,比如Disruptor,充分利用了volatile的可见性规则,成功地减少了锁的使用。需要非常高的编程技巧。

有了可见性的基础知识,现在可以开始讨论安全共享对象的问题。

对象发布与逃逸(publication&escape)

发布对象

共享对象的第一个步骤是发布对象,发布一个对象意味着该对象可在当前作用域之外可被访问,常见的方式有:

  • 将对象引用存入某个可被其它代码访问的变量;
  • 从一个非private方法返回它的引用;
  • 将对象引用作为参数传递给其它对象方法。

简而言之,就是将一个对象暴露给外部代码,使得它可能被任意线程所访问。一个对象一旦发布除去,除了最基本的java权限约束,你将无法预测该对象会被如何使用。

从封装性角度讲,我们应该避免发布对象或对象的内部状态,但这是不可完全避免的,在多线程环境下正确地发布对象需要采用一定的同步手段。

发布一个对象A间接地发布了其他对象,包括A的noprivate字段指向的对象,和noprivate方法返回的对象;或者更普遍地,所有以A为起点,通过noprivate路径可达的对象。

安全地发布对象,要求接受对象的代码能够读到正确的对象状态,有时候也称作初始化安全。这看起来是天经地义,不需要讨论的事情,在多线程环境下却暗藏玄机。

逃逸

意外的对象发布称之为逃逸,请看以下代码:

public class ThisEscape {
	public ThisEscape(EventSource source) {
		source.registerListener(
			new EventListener() {
				public void onEvent(Event e) {
					doSomething(e);
				}
		});
	}
}

ThisEscape在构造构造函数中,使用了内部类来实现接口,并将内部类的实例传递给了source。由于内部类的实例实际上包含一个隐式的this指针,所以上面的代码将未构造完成的ThisEscape实例(构造函数尚未执行完),发布给了EventSource。

是否发生“逃逸”取决于“对象的发布是否是意外造成的“,这可能与具体的引用场景有关,但发布未构建完成的对象肯定是逃逸

另外一个容易发生逃逸的场景是在构造函数里创建并启动线程。

public class ThisEscape extends Runnable {
	public ThisEscape() {
		new Thread(this).start()
	}
	...
}

我们要完全避免在构造函数内发布对象,即使在最后一句代码也不行,上面的第一种情况,通过工厂方法可以解决:

public class ThisEscape {
	public static ThisEscape createInstance(EventSource source) {
		ThisEscape object = new ThisEscape();
		source.registerListener(
			new EventListener() {
				public void onEvent(Event e) {
					object.doSomething(e);
				}
		});
		return object;
	}
}

第二种情况只要别在构造函数内部start线程就行,可另外提供一个start方法。

线程封闭(Thread Confinement)

在讨论如何安全发布对象之前,我们先看一下避免发布对象的设计策略:线程封闭。

将某个对象的访问限制在单个线程,这种技术称之为线程封闭(Thread Confinement),它是实现线程安全性的最简单方式之一。这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的。

使用该技术的一个案例是,JDBC连接池,并不要求Connection对象本身是线程安全的,因为通常我们在需要时从连接池获取一个Connection,使用完后立即退回给连接池,整个过程发生在一个同步方法调用中。

然而,java语言并没有任何机制将对象封闭在某个线程,这是开发者自己的责任。

栈封闭(Stack Confinement)

栈封闭是线程封闭的一种特殊情况,它将对象限制在方法栈内,绝不传递对象引用到栈外的作用域,自然保证了线程安全。

栈封闭的脆弱之处在于“将某个对象封闭在栈内”这个设计往往只存在于代码编写者的头脑里,如果缺少清晰的注释,将来的维护者可能将该对象发布出去。

ThreadLocal

ThreadLocal是一种比较正式的线程封闭技术,它是一个对象容器,每个线程读写它时操作的是当前线程独立的副本。ThreadLocal常用的场景是,将某些可变全局对象,限制为线程范围内有效。

使用ThreadLocal的坏处是,它在看起来风马牛逼不相及的代码块之间引入非常隐蔽的耦合,并不适合广泛使用;而且,如果开发者不主动释放对象,对象的生命周期与线程一致,这可能是一个问题。

安全发布

合理构造

一个对象要安全发布,首先要求它是合理构造的,合理构造的含义是:对象在初始化过程中没有发生逃逸。一个未合理构造的对象,即使在单线程下也是不安全的,因此合理构造是一个基本前提。

不安全发布示例

先看一段典型不安全发布对象的代码:

// Unsafe publication
public Holder holder;

public void initialize() {
	holder = new Holder(42);
}

如果Holder是一个可变对象,由于可见性问题,上面的发布方式在多线程环境下是不安全的,哪怕没有其他线程修改holder对象。另外一个线程在使用holder时可能遇到以下两个问题:

  • 看到一个过期的holder对象,而不是新构造的
  • 看到一个未构造完成的holder对象

下面这段代码展示了一种令人惊恐的现象,assertSanity方法在多线程下可能抛出AssertionError异常。

public class Holder {
	private int n;
	
	public Holder(int n) {
		this.n = n; 
	}
	public void assertSanity() {
		if (n != n)
		throw new AssertionError("This statement is false.");
	}
}

安全发布是从可见性角度分析线程安全性,所谓安全地发布一个对象,是指一个新对象被发布之后,其他线程访问到完整&一致的对象状态。安全发布另一种说法是初始化安全性

不可变对象的安全发布

java对不可变对象有一些特殊的保证,使得它的发布变得简单。

并不是将对象的所有字段声明为final,它的状态就是不可变的,如果字段是一个对象引用,那么它指向的对象仍有可能是可变的。(这里还取决于对象的状态的范围界定,是否包括依赖的对象)

不可变对象的需要满足三个条件:

  • 一旦创建完成,它包含的状态就不可修改(实际不可变)
  • 它的全部字段都被final关键字修饰 (形式上不可变)
  • 它被合理地构建(它的引用没有在构建期间逃逸)

不可变对象可以包含可变状态,只要不提供修改该状态的接口,且能保证不将可变状态发布出去。

尽管将final关键字放在引用类型字段上,只能保证该字段在生命周期内不指向别的对象,但它所指向的对象可能是可变的,但引用类型上的final关键字并非多余。一方面,它能够简化对象的状态管理,另一方面,JVM对它的可见性做了额外的保证:一个final字段一旦被初始化,它对所有线程是可见的

因此不可变对象只要保证合理构造的,任何发布方式都是安全的。

volatile引用配合不可变对象

上一章介绍的SafeCacheServlet示例通过加锁来保证线程安全性,通过将Sevlet的可变状态封装为不可变对象,再配合volatile关键字,不需要加锁就可安全地发布状态修改:

public class SafeCacheServlet implements Servlet {

		private volatile ResultCache cache = new ResultCache(0,0);
		
		public void service(ServletRequest req, ServletResponse resp) {
			long p = extractFromRequest(req);
			ResultCache temp = cache;
			if (temp.getParam()==p) {
				writeToResponse(resp, temp.getResult())
			} else {
				long r = computeResult(p);
				temp = new ResultCache(p,r);
				writeToResponse(resp,r)
			}
		}
}

可变对象的安全发布

而一个可变对象,要做到安全发布,需要采用以下手段之一:

  • 通过static初始化代码块(static initializer)将对象引用赋值给变量;
  • 将新构造对象的引用存入一个volatile变量,或AtomicReference;
  • 将新构造对象的引用存入一个合理构造的对象的final字段;
  • 将新构造对象的引用存入一个被锁保护的字段;

JDK的线程安全容器类,满足了以上第二点或第四点要求,所以将一个新创建的对象放入安全容器,由另一个线程取出来,是一种常用的安全发布手段。

值得注意的是,安全发布一个对象,相当于同时安全发布了该对象引用的其他对象。

安全共享

安全发布是对象安全共享的第一步,第二步是要求对象在多线程下被安全地访问。首先不可变对象由于状态不可修改,所以天然可以被安全地共享。

还有一种对象,它虽然在技术上,不符合可变对象的定义,但是一旦被创建,就再也没有任何线程修改它,这种对象叫做实际不可变对象

这样一来,一个对象能否被安全共享,有以下几种情况:

  • 不可变对象:天然线程安全,只要合理构造,任何发布和共享方式都OK;
  • 实际不可变对象:需要被安全发布;
  • 可变对象:需要被安全发布,且在多线程下访问需要锁(或其他线程同步技术)的保护;

总结:对象的安全共享策略

如何安全地共享对象,不是一个单纯的技术问题,而是在设计阶段就应该细细思量的事;先要定下了对象的安全共享策略,然后再选取适当的线程同步技术。大多数多线程相关bug产生的原因不是对象不够线程安全,而是开发者没有正确使用它。

安全地在多线程环境共享一个对象,包含两个环节:第一个是安全地发布,让目标线程能够得到对象引用,且读取到对象的正确状态;第二个是安全地共享,多个线程并发地访问对象。

这两个环节,需要从对象的线程安全策略出发来考虑:

  • 对象是否是线程封闭的,不可以被其他线程访问;
  • 只读共享:对象可以被多线程共享,但不能修改它的状态;
  • 可安全共享:对象自身是线程安全的(不可变对象或内部已有线程同步机制),多线程可自由地访问它;
  • 加锁共享:对象需要使用者加锁才可共享。

不预先思考对象的线程安全策略,一上来就搬出JDK的各种并发组件,是无法编写出正确、高效、安全的并发代码的。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值