《Java并发编程实践》二(2):什么是线程安全

第2~5章是原书的第二部分,介绍多线程的基础知识,分别是:

  • 第2章:线程安全性
  • 第3章:共享对象
  • 第4章:组合对象的线程安全性
  • 第5章:线程安全组件

第2章介绍线程安全性的基本概念,内容如下。

状态

每当我们谈及线程安全的话题,都会想到Thread, Lock这些词语,不过这些都是线程安全相关的工具,就像建筑桥梁所用的钉子、砖头。线程安全真正关注的核心问题是“正确地管理程序状态”,更具体是“管理共享可变状态”。

状态可简单理解为内存数据,在java世界里,状态就是指存储在状态变量里的数据,状态变量可以是对象实例的字段,也可能是类的静态字段。对象的状态包括任何影响该对象外部行为的字内部数据,因此,一个对象的状态不仅包括于它的直接基本类型字段的状态,也包括了它所的引用的其他对象的状态。

并不是对象的所有字段都是它的状态,如果某个字段并不影响对象的外部行为,那么就不是对象状态的一部分。

在java里,状态往往是以对象的形式存在,为了叙述方便,下文会在适当的场合用“对象”这个称谓来代替“状态”。

共享可变状态

这里的共享是指对象可在多个线程中被访问,可变指在对象生命周期中它的状态可能发生变化。如果对象只会被一个线程使用,那么就不会有线程安全问题;同样,如果对象是不可变的,那么即使被多个线程访问,也不会有安全问题。

这个概念提供了两个指引:

1、一方面我们应该多使用非共享,或不可变的对象,以降低线程安全的风险;
2、另一方面,我们必须对共享可变对象采用某种线程安全手段。

多个线程访问一个共享可变对象,如果不采取线程同步措施,可能导致数据崩溃或其他错误行为。

什么是线程安全

我们经常听到一种说法,“一个线程安全的对象,可以在多线程中被安全地使用”,这其实是一句循环解释的废话。我们需要借助“正确性”来定义线程的安全性,正确性可从两方面定义:

  • 不变式约束 (invarianty)
    一个对象的状态必然要满足某些状态约束,比如一个地理坐标对象,内含经纬度坐标值(x,y),显然它必然满足一些条件才能成为一个有效的坐标值。

  • 满足后置条件(postcondition)
    对象在某个状态下,执行了一个操作,需要到达一个正确的新状态;比如向一个空的集合add一个元素,那么集合的状态必须是”非空“。

于是,我们可以这样定义线程安全性:一个对象是线程安全的,指当对象被多个线程访问时,无论线程被如何调度,线程之间的代码执行如何交错,它都能满足“正确性”要求。

无状态对象

假设一个对象没有任何影响外部行为的字段,这叫做无状态对象。因为线程安全是关于状态的,所以无状态对象天然就是线程安全的。

此类对象的典型是:工具算法类。

对象的线程安全性如何?

一个对象,我们是考虑它的线程安全属性,不仅取决该对象的状态及行为,更重要是该对象将会被如何使用。是否要将一个对象设计为线程安全的,是一个设计阶段的决定;几乎所有的GUI Framework相关对象都不是线程安全的,因为
这里的设计决策就是“只能在单一线程操作GUI组件”。

将一个非线程安全的对象改造为线程安全,几乎相当于重写;虽然设计线程安全对象需要大量线程同步的技术,但遵循面向对象设计规范是一个良好的开端。

操作的原子性

下面是一段具备记录执行次数的Servlet实现代码:

public class UnsafeCountingFactorizer implements Servlet {

		private long count = 0;
		
		public long getCount() { return count; }
		
		public void service(ServletRequest req, ServletResponse resp) {
			++count;
		}
}

有一定java基础的人一定会判定,该类不是线程安全的,因为++count不是一个原子操作,CPU实际执行了”加载变量、加法计算、写入变量“三个指令,在多线程环境下,count记录的次数可能会比实际发生的要少。

那么UnsafeCountingFactorizer是否是线程安全的呢?从技术角度来讲,肯定不是。在具体应用场景下,取决于你对正确性的定义,如果我们只想大致记录一下该servlet执行的次数,不关注精确性,那么可以认为它是线程安全的。

竞争条件

如果程序的正确性取决于多个线程之间指令执行的相对顺序,那么说明存在竞争条件。换句话说,程序能否正确运行需要看运气,竞争一词形象地描述了多个线程的相对执行顺序的不确定性。

上面UnsafeCountingFactorizer就存在一个竞争条件,这是典型的"read->modify->write“竞争条件。另外一个常见的竞争条件是"check->then->act":检查某个条件然后执行某个操作,线程竞争可能导致执行操作时条件不再满足。

下面的延迟初始化单例模式实现,包含“check->then->act”模式的竞争条件:

@NotThreadSafe
public class LazyInitRace {
	private ExpensiveObject instance = null;
	public ExpensiveObject getInstance() {
		if (instance == null)
			instance = new ExpensiveObject();
		return instance;
	}
}

组合操作

竞争条件存在的根源在于组合操作的“非原子性”,对于UnsafeCountingFactorizer来说,如果"read->modify->write“这个组合操作是原子操作,那么竞争条件就不复存在。

这里的原子操作是一个相对的概念:一个操作A和操作B(AB可能等价)互为原子操作,意味着当一个线程在执行A时,那么一定没有另外一个线程正在执行B,反过来亦然。

Atomic类

UnsafeCountingFactorizer只包含一个int类型的状态,可以使用Atomic类型来封装它,使得它的更新操作成为原子操作。

public class UnsafeCountingFactorizer implements Servlet {

		private AtomicLong count = new AtomicLong(0);
		
		public long getCount() { return count.get(); }
		
		public void service(ServletRequest req, ServletResponse resp) {
			count.incrementAndGet();
		}
}

jdk的java.util.concurrent.atomic包含一系列Atomic*类型,来支持基本类型的原子修改操作。但是如果对象状态涉及多个字段,那么Atomic类型就无能为力了。

假如有一个提供计算服务的servlet,它接受一个long参数,并返回一个long型的计算结果;由于这个计算是一个很耗时的操作,我们准备在servlet里缓存最后一次计算结果,如果恰好发生连续的、相同参数的调用,可直接使用缓存的结果。

public class UnsafeCacheServlet implements Servlet {

		private AtomicLong param = new AtomicLong(0);
		private AtomicLong result = new AtomicLong(0);
		
		public void service(ServletRequest req, ServletResponse resp) {
			long p = extractFromRequest(req);
			if (p==param.get()) {
				writeToResponse(resp,result.get())
			} else {
				long r = computeResult(p);
				result.set(r)
				writeToResponse(resp,r)
			}
		}
}

尽管Servlet使用了线程安全的字段类型,但是它自身不是线程安全的,因为param和result之间存在约束关系,并不互相独立,必须确保在同一个原子操作中修改二者。

监视器锁(monitor lock)

Monitor锁是java内置的一种锁,它的用法如下:

synchronized (lock) {
	// Access or modify shared state guarded by lock
}

synchronized使用的lock是任意的java对象,使用同一个lock对象的synchronized代码块互为原子操作。任意一个线程在进入这个代码块会先加锁,如果另个一线程正拥有lock监视锁,线程会等待后者释放锁。当线程离开这个代码块时,就会立刻释放锁,即使发生异常。

synchronized可以加在方法上,表示以当前对象为锁,对整个方法体加锁,用该方法可以轻易地将UnsafeCacheServlet变成线程安全的。

public class SafeCacheServlet implements Servlet {

		private long param = 0L;
		private long result = 0L;
		
		public synchronized  void service(ServletRequest req, ServletResponse resp) {
			long p = extractFromRequest(req);
			if (p==param) {
				writeToResponse(resp,result)
			} else {
				long r = computeResult(p);
				param  = p;
				result = r;
				writeToResponse(resp,r)
			}
		}
}
  • 互斥性
    监视器锁具备互斥性,也就是同一时刻,只有同一个线程持有监视器锁。

  • 可重入性(Reentrancy)
    如果线程正持有一个监视器锁,那么该线程可进入同一锁对象保护的代码块,这说明监视器锁是可重入的。可重入锁,是针对线程加锁,而不是针对方法调用加锁,这点很重要,便于我们封装代码,不至于引起意外的死锁。

用锁保护状态

在SafeCacheServlet的例子中,我们用synchronized关键字使得对param和result的修改成为原子操作,假设我们增加以下方法允许外部访问缓存结果:

public class SafeCacheServlet implements Servlet {

		private long param = 0;
		private long result = 0;
		
		public synchronized  long getResultIfCached(int p) {
			if (param==p) {
				return result;
			}
			return -1
		}
}

尽管方法getResultIfCached没有修改状态,但是仍然需要加上synchronized关键字,否则可能返回错误的结果。这说明,我们必须将所有对某个状态的访问操作,都加上同一个锁,才能保证线程安全性。

锁是用来保护状态的,一个或一组状态用一个锁来保护,所有涉及该(组)状态的读写操作都必须先加锁。

在实际的设计中,必须非常清楚地指明:状态使用哪一个锁来保护的,否则代码难以维护。

锁的性能风险

由于SafeCacheServlet对service整个方法加锁,相当于拒绝了该方法的并发访问,在一个多核CPU的机器上,这是一段性能很差劲的代码。我们可以按以下方式改进性能:

public class SafeCacheServlet implements Servlet {

		private long param = 0L;
		private long result = 0L;
		
		public  void service(ServletRequest req, ServletResponse resp) {
			long p = extractFromRequest(req);
			synchronized(this) {
				if (p==param) {
					writeToResponse(resp,result)
				 }
			 }
		 
			long r = computeResult(p);
			synchronized(this) {
				param  = p;
				result = r;
			}
			writeToResponse(resp,r)
			
		}
}

改进的方式是缩小了锁覆盖的代码范围,我们再次提醒自己,需要保护的不是代码而是状态,所以上面将访问状态的两段代码分别用锁覆盖。

上面的改进之所以是必要的,是因为computeResult是一个耗时的操作,否则将同步代码块一拆为二未必能提升性能,毕竟加锁&释放锁也有些许性能消耗。所以在实际工作中,所要同步的方法如果没有包含IO操作、复杂耗时操作,或其他可能导致阻塞的操作,更推荐将锁加在方法上。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值