第2章-线程安全性

2/10/2017来源:ASP.NET技巧人气:827

在构建稳健的并发程序时,必须正确地使用线程和锁,但这些终归是一些机制。要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的(Shared)和可变的(Mutable)状态的访问。

从非正式的意义上说,对象的状态是指存储在状态变量(例如实例或静态域)中的数据。对象的状态可能包括其他依赖对象的域,比如某个HashMap的状态不仅存储在HashMap对象本身,还存储在许多Map.Entry对象中。

共享 意味着变量可以由多个线程同时访问 可变 意味着变量的值在其生命周期内可以发生变化

一个对象是否需要是线程安全的,取决于它是否被多个线程访问。

当多个线程访问某个状态变量并且其中有一个线程执行写入操作(说明是是共享 可变的)时,必须采用同步机制来协同这些线程对变量的访问。(保证线程安全性)

java中主要的同步机制:

synchronized volatile类型的变量 显式锁(Explicit Lock) 原子变量

变量为线程安全的方法组合:

不共享 共享+不可变 共享+可变+同步

程序状态的封装性越好,就越容易实现程序的线程安全性,并且代码的维护人员也越容易保持这种方式。

线程安全的程序不一定完全由线程安全类构成。(可以有非线程安全类,然后在程序中增加同步措施) 完全由线程安全类构成的程序并不一定就是线程安全的。(两个线程安全类不同锁,构成的程序不能保证原子性) 线程安全类中也可以包含非线程安全的类(同上,只要再增加同步措施即可)

线程安全性定义:当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。正确性的含义是,某个类的行为与其规范一致。(如果你觉得‘正确性’的定义有些模糊,那么可以将线程安全类认为是一个在并发环境和单线程环境中都不会被破坏的类)

通常,线程安全性的需求并非来源于对线程的直接使用,而是使用像Servlet这样的框架。

无状态对象一定是线程安全的。

竞态条件(Race Condition):由于不恰当的执行时序而出现了不正确的结果(出现这种状况则不是线程安全的,因为违反了线程安全性的定义)。当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件,换句话说,就是正确的结果取决于运气。

数据竞争(Data Race):如果在访问共享的非final类型的域(共享 可变)时没有采用同步来进行协同,那么就会出现数据竞争。在java内存模型中,如果在代码中存在数据竞争,那么这段代码就没有确定的语义。

并非所有的竞态条件都是数据竞争,同样并非所有的数据竞争都是竞态条件。???

竞态条件的类型:

读取-修改-写入(++count 操作并非原子,结果状态依赖于之前的状态) 先检查后执行(Check-Then-Act,通过一个可能失效的观测结果来做出判断或者执行某个计算)

使用“先检查后执行”的一种常见情况就是延迟初始化。延迟初始化的母的:

将对象的初始化操作推迟到实际被使用时才进行 确保只被初始化一次。

与大多数并发错误一样,竞态条件并不总是产生错误,还需要某种不恰当的执行时序。

要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量。也就是说必须原子操作来避免产生竞态条件。

原子操作:对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作 一个 以原子方式执行的操作。

如果++count是一个原子操作,那么竞态条件就不会发生。 使++count不会发生竞态条件的方法

加锁,确保原子性 使用线程安全类,将count声明为AtomicLong类型

当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。(0 –> 1 当0/1变多时,并不是这么简单)

当一个类引入了多个状态变量时,状态变量之间可能不是彼此独立的,而是某个变量的值会对其他变量的值产生约束,这时,要保持状态的一致性(也就是保证线程安全),就需要在单个原子操作中更新所有相关的状态变量。

同步机制的两个重要方面:

原子性 可见性

同步代码块包含两部分:

作为锁的对象引用 作为由这个锁保护的代码块

每个java对象都可以用作一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或者监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。

java的内置锁相当于一种互斥体(或互斥锁 mutex),这意味着最多只有一个线程能够持有这种锁。

并发环境中的原子性与实务应用程序中的原子性有着相同的含义———一组语句作为一个不可分割的单元被执行。

内置锁是可重入的(reentrant),也就是说如果某个线程试图获得一个已经由他自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作粒度是“线程”,而不是“调用”(这与pthread(POSIX线程)互斥体的默认加锁行为不同,pthread互斥体的获取操作是以“调用”为粒度的)。

重入进一步提升了加锁行为的封装性。在java中子类改写父类synchronized方法,然后在其中调用父类的方法,如果没有可重入的锁,那么这段代码将产生死锁。

对于可能被多个线程同时访问的可变状态变量,在访问它时需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。

一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问

对于包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。

如果同步可以避免竞态条件的问题,那么为什么不在每个方法声明时都是用关键字synchronized?

如果不加区别的滥用synchronized,可能导致程序中出现过多的同步 如果只是将每个方法都作为同步方法,那么并不足以确保Vector上复合操作都是原子的,比如在程序代码中使用Vector: if(!vector.contains(element)) vector.add(element);

contains和add方法均为syn方法,但是上面这段代码为先检查后执行,存在竞态条件,需要将这两个操作合并为复合操作。

不良并发(Poor concurrency)应用程序:可同时调用的数量不仅受到可用处理资源的限制,还受到应用程序本身结构的限制。

缩小同步代码块的作用范围,可以确保程序的并发性,同时又维护线程安全性。但是,如果将同步代码块分解的过细,那么在获取锁与释放锁等操作上都需要一定的开销。

当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络I/O或者控制台I/O),一定不要持有锁。