Java并发:线程安全-理论

线程安全概念

线程安全:当多个线程访问一个对象,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要额外的同步,或者在调用方进行任何其他的协调操作, 调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。

即要求线程安全的代码都必须具备一个特征:代码本身封装了所有必要的正确性保障手段(互斥同步等),令调用者无须关系多线程的问题,更无须自己采用任何措施来保证多线程的正确调用。但这个并不容易做到。

因此一般将定义弱化一些,即将调用这个对象的行为限定为单次调用,若其他描述成立,则称它线程安全。

在一项工作进行前,会被不停中断与切换,对象的属性可能会在中断期间被修改和变脏。如何保证程序在计算机中准确无误地运行。

概念

编写线程安全的代码,本质上就是管理对状态的访问,而且通常都是共享、可变的状态。

一个对象的状态就是它的数据,存储在状态变量中,如静态域、实例域。共享即一个变量可以被多个线程访问。可变即变量的值在其生命周期内都可以改变。真正要做到的线程安全是在不可控制的并发访问当中保护数据。

无论何时,只要有多于一个的线程访问给定的状态变量,而且其中某个线程会写入该变量,此时必须使用同步来协调线程对该变量的访问。

java的锁机制:synchronized(独占锁),volatile、显示锁和原子变量的使用

修复同步隐患

  • 不跨线程的共享变量
  • 使用状态变量为不可变的
  • 在任何访问状态变量的时候使用同步

原子性

当我们在一个无状态对象中增加一个状态时,例如增加一个命中计数器count++来统计所处理的请求数量。

但是在多线程下,这种自增操作可能会丢失一些更新操作,该语句会转换为多个字节码指令,包含3个独立的操作:读取、修改、写入。其结果状态依赖于之前的状态,即这三个操作并不能作为一个原子操作。

而多线程在没有同步的情况下对一个count进行操作,如果初始值为0,则可能会出现每个线程读取得到的指都时0,之后进行递增操作,并将计数器的值设为1,之后写入。在这个过程中丢失了一次更改。

在Web服务中,命中计数器的少量偏差是我们可以接受的, 但是如果我们是在生成唯一的对象标识符,那么将导致严重的问题。

竞态条件

竞态条件:由于不恰当的执行时序而出现不正确的结果,即结果的出现依赖于线程的执行顺序

当某个计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。即正确的结果依赖于运气

常见场景

  • 先检查后执行,即检查-修改。然鹅在检查-修改的中间时间,观察结果可能会失效,从而导致各种问题
    • 延迟初始化,确保只初始化一次。在多线程下可能会初始多次。

竞态条件并不总是产生错误,需要某种不恰当的执行时序。

数据竞争

数据竞争:如果在访问共享的非final类型的域时没有采用同步来进行协同,那么就会出现数据竞争。当一个线程写入一个变量而另一个线程接下来读取这个变量,或者读取一个之前由另一个线程写入的变量时,并且在这两个线程间没有使用同步,就可能会出现数据竞争。

如果代码存在数据竞争,那么这段代码就没有确定的语义

并非所有的竞态条件都是数据竞争,也并非所有的数据竞争都是竞态条件,但二者都可能导致并非程序失败。

复合操作

要避免竞态条件,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。

原子操作:假定有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说就是原子的。

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

加锁机制

当在Servlet中添加了一个状态变量,可以通过线程安全的对象来管理Servlet的状态来维护其线程安全性,但是当想在Servlet添加更多的状态,是否只需要增加更多的线程安全状态变量就可以了。

然而尽管两个状态变量都是安全的,但是对两个安全的状态变量进行操作并不一定是安全的,即它们独立的操作都是原子的,而对它们两个的操作是两个原子操作,在两次操作间存在空隙,因此不是一个原子操作,而存在竞态条件。

要保证状态的一致性,就需要在单个原子操作中更新所有相关的状态变量

内置锁

Java提供了一种内置的锁机制来支持原子性,即同步代码块synchronized,同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。之所以内置锁只是为了免去显式创建锁对象,

以synchronized修饰的方法是一种横跨整个方法体的同步代码块,锁就是方法调用所在的对象。静态的synchronized以Class对象为锁。

每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁或监视器锁(Monitor)。获得内置锁的唯一途径就是进入由整个锁保护的同步代码块或方法。

而内置锁的存在使得线程安全变得简单,但是却过于极端,导致服务的响应性会很低,即一个性能问题。

重入

当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞,然而由于内置锁可重入,即当某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。

重入意味着锁的操作粒度是线程,而不是调用。重入的一种实现是为每个锁关联一个获取计数值和一个所有者线程,当计数值为0时,这个锁被认为是没有被任何线程持有,当线程请求一个未被持有的锁时,JVM记录锁的持有者,并将计数器+1。

重入使得子类如果重写了父类的synchronized方法,之后调用父类的方法不会产生死锁。

理解:父类的方法与子类本身的方法都是在子类的方法表当中,即这些方法归属于同一个类,同一个对象,而不是说有两个对象。因此在调用子类的synchronized方法时首先获得了该对象的锁,此时调用父类的方法,如果不是可重入的,则因为该对象的锁已经被获取而发生死锁。

1
2
3
4
5
6
7
8
9
10
11
public class Widget{
public synchronized void do(){

}
}
public class log extends Widget{
@Override
public synchronized void do(){
super.do();
}
}

因为两个do都时synchronized的,因此每个方法都会在执行前获取Widget的锁,如果不可重入,则当调用super.do时会阻塞。而重入避免了该情况

用锁来保护状态

锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问,只要始终遵循这些协议就能保持状态的一致性。

访问共享状态的复合操作都必须是原子操作以避免产生竞态条件,如果在复合操作中持有一个锁,则会使得复合操作称为原子操作。仅仅将复合操作封装到一个同步代码块中是不够的,如果使用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。

常见的错误是只有在写入共享变量时才需要使用同步。

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

每个共享的和可变的变量都应该只有一个锁来保护,从而使维护人员知道是哪一个锁

一种常见的加锁约定是将所有的可变状态都封装在对象内部,帮通过对象的内置锁对所有访问可边状态的代码路径进行同步,使得在该对象上不会发生并发访问。但是这种模式不会得到强制,即如果在添加新的方法时忘记使用了同步,则加锁协议就被破坏了。

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

活跃性与性能

对于Servlet,如果通过Servlet对象的内置锁保护每一个状态变量,即对整个service方法进行同步,这种简单且粗粒度的方法能确保线程安全性,但是代价很高。service如果同步了,则每次只能有应该线程可以执行,背离了Servlet的初衷,即需要能同时处理多个请求,在负载过高的情况下将给用户带来糟糕的体验。

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

通过缩小同步代码块的作用范围可以很容易做到既确保Servlet的并发性,同时又维护线程安全性。要确保同步代码块不要过小,并且不要将本应时原子的操作拆分到多个同步代码块中,尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。

应当在程序中使用一种同步机制,两种不同的同步机制会带来混乱,并且不会在性能或安全性上带来任何好处,例如在synchronized中使用Atomic

判断同步代码块的合理大小。需要权衡安全性、简单性、性能。

通常,在简单性和性能间存在相互制约因素。当实现某个同步策略时,一定不要盲目地未了性能而牺牲简单性(可能会破坏安全性)

当使用锁时,应当清楚代码块中实现地功能,以及在执行该代码块时是否需要很长时间。如果需要,则都会带来活跃性或性能问题。

当执行时间较长地计算或者可能无法快速完成地操作时,例如网络IO或IO,一定不要持有锁。

总结

  • 编写线程安全的代码,本质上就是管理对状态的访问,而且通常都是共享、可变的状态。
    • 动机:无论何时,只要有多于一个的线程访问给定的状态变量,而且其中某个线程会写入该变量,此时必须使用同步来协调线程对该变量的访问。
      • 要保证状态的一致性,就需要在单个原子操作中更新所有相关的状态变量
      • 并不仅仅是只需要在写入的时候需要同步
      • 每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁(封装在对象中)
  • 同步将使得性能降低。判断同步代码块地合理大小
    • 当执行时间较长地计算或者可能无法快速完成地操作时,例如网络IO或IO,一定不要持有锁。
  • 加锁的机制不仅仅局限于互斥行为,还包括内存可见性,为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。

实现线程安全

实现线程安全有两种方式:

  • 对象的共享。需要自己去设计线程安全的类
  • 对象的组合。通过将对象的线程安全委托给其他类

对象的共享

如何共享和发布对象,从而使它们能够安全地由多个线程同时访问。并且实现内存可见性。

内存可见性:希望确保当一个线程修改了对象状态后,其他线程能够看到发生地状态变化。

线程安全级别

线程安全限定于多个线程间存在共享数据访问这个前提,如果一段代码根本不会与其他线程共享数据,那么从线程安全的角度看,程序是串行执行还是多线程执行对它没有区别

依照线程安全的安全程度排序来看,Java中各种操作共享的数据分为以下5类:

不可变

final对象,不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要采用任何的线程安全保障措施。

只要一个不可变的对象被正确地构建出来(this引用没有逃逸),那其外部的可见状态永远也不会改变,永远也不会看到它在多个线程中处于不一致的状态。

  • 对于一个数据类型,只需要在定义时使用final即可
  • 对于一个对象,则需要保证对象的行为不会对其状态产生任何影响才行。例如String,subString会返回一个新的字符串,对其本身没有影响。

绝对线程安全

指完全满足定义。而即使一个类的所有方法都时被synchronized修饰的,也不意味着调用它永远都不需要同步手段了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static Vector<Integer> vector = new Vector<>();
public static void main(String[] args){
while(true){
for(int i=0; i < 10; i++){
vector.add(i);
}
}
Thread removeThread = new Thread(() -> {
for(int i = 0; i < vector.size(); i++){
vector.remove();
}
});
Thread printThread = new Thread(() -> {
for(int i = 0; i < vector.size(); i++){
System.out.println(vector.get(i));
}
});
removeThread.start();
printThread.start();
}

在上面的示例当中,依然时线程不安全的,当remove刚好删除一个元素,导致序号i已经不可用,此时去访问数组会抛出ArrayIndexOutOfBoundsException

而要改为绝对安全则需要将synchronized(vector)

相对线程安全

保证单次调用下,线程是安全的,对于特定顺序的连续调用,则可能需要使用额外的同步手段。Java中的大多数线程安全类都属于该类型

线程兼容

对象本身并不是线程安全,但可以在调用端正确使用同步手段来保证对象在并发环境可以安全使用。一般常说类不是线程安全的绝大部分是指该情况。

线程对立

无论调用端是否采用了同步措施,都无法在多线程环境并发使用的代码。

例如Thread的suspend()resume(),如果两个线程同时持有一个线程对象,一个尝试中断线程,另一个尝试恢复线程。当并发进行时,无论调用是否同步,目标线程都存在死锁风险。

内存可见性

导致共享变量在线程间不可见的原因

  • 线程交叉执行
  • 重排序结合线程交叉执行
  • 共享变量更新后的值没有在工作内存与主内存间及时更新

因为我认为这块属于JMM方面,因此详情查看Java并发:JMM

发布与逸出

发布

发布对象:发布一个对象指它能够被当前作用域以外的代码所使用。发布一个对象,同时将发布该对象所有的非私有域引用的对象。

发布对象的场景:例如将应该指向该对象的引用保存到其他代码可以访问的地方,或者在某一个非私有的方法中返回该引用,或者将引用传递到其他类的方法中。

很多情况下,我们确保对象及其内部状态不被发布。而某些情况下,我们又需要发布某个对象,如果在发布时要确保线程安全,则可能需要同步。发布内部状态可能会破坏封装性,并使得程序难以维持不变性条件。不安全发布的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class UnsafePublish {

private String[] states = {"a","b","c"};

/**
* 直接获得了私有对象states的引用
* @return
*/
public String[] getStates(){
return states;
}

public static void main(String[] args){
UnsafePublish unsafePublish = new UnsafePublish();
log.info("{}", Arrays.toString(unsafePublish.getStates()));
//发布states对象,无法确定其他线程是否会修改该对象的数据
//因此在使用这个对象的数据的时候,无法完全确定对象里面的数据
//即线程不安全的
unsafePublish.getStates()[0] = "d";
log.info("{}", Arrays.toString(unsafePublish.getStates()));
}
}

逸出

对象逸出:一个对象在尚未准备好的时候就发布,使得它被其他线程可见。

逸出会破坏线程安全性,当应该对象逸出后,你必须假设有某个类或线程可能会误用对象,这正是使用封装的最主要原因。封装能够使得对程序的正确性进行分析变得可能,使得无意中破坏涉及约束条件变得更难。

逸出:

  • this引用在构造期间逸出,即对象在没有通过构造函数构造完毕(执行到了构造函数的某一句)时候逸出。
    • 当对象在构造函数当中创建一个线程,this引用总是被新线程共享
    • 当发布一个内部的类的实例,也会隐式发布了实例本身,因为内部类的实例中包含了对其原类的实例的隐含引用

如果要在构造函数中创建线程

  • 使用工厂方法或者私有构造函数来完成
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class SafeListener {

private final EventListener listener;

private SafeListener(){
//在这里启动了一个线程,新线程已经可以看到escape类的对象
listener = new EventListener(){
public void onEvent(Event e){
do(e);
}
}
}

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

线程封闭

访问共享的、可变的数据要求使用同步。一个可以避免同步的方法就是不共享数据。当对象封装在一个线程当中,则自动成为线程安全的。即使被封闭的对象本身不是线程安全的。

线程封闭:如果数据仅仅在单线程当中访问,则不需要任何同步

Java中并没有强制规定某个变量必须由锁来保护,同样在Java语言中也无法强制将对象封闭在某个线程中,Java通过使用局部变量和ThreadLocal实现。

如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

示例

符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 Web 交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。

  • Swing将事件分发到线程当中
  • JDBC从池中分配一个对象给线程。

线程封闭方法:

Ad-hoc线程封闭:维护线程封闭性完全由程序实现来承担。

程序控制实现,最糟糕。因为没有任何一种语言特性能够将对象封闭到目标线程上。

volatile存在一种特殊的线程访问,确保只通过单一线程写入共享的volatile变量,则操作便是共享

堆栈封闭:局部变量,无并发问题。

是线程限制的特例,只能通过局部变量才可以触及对象。本地变量使得对象更容易被限制在线程本地中,本地变量本身就被限制在执行线程中,它们存在于执行线程栈。其他线程无法访问这个栈

堆栈封闭比Ad-hoc更易于维护,也更健壮。JVM保证了基本类型的局部变量始终封闭在堆栈中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class StackClosedExample {
public void add100() {
int cnt = 0;
for (int i = 0; i < 100; i++) {
cnt++;
}
System.out.println(cnt);
}
}
public static void main(String[] args) {
StackClosedExample example = new StackClosedExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> example.add100());
executorService.execute(() -> example.add100());
executorService.shutdown();
}
100
100

例如下面方法当中的numPairs。在该方法当中,实例化的animals只有一个引用指向它,因此它保存在线程的栈当中,确保了不会破坏栈封闭性。倘若发布了animals或其内部对象的引用,则破坏了限制,并导致了对象逸出。

如果在线程内部上下文使用非线程安全的对象,那么该对象仍然时线程安全的,但是只有开发代码的人员才知道那些对象需要被封闭到执行线程中,以及被封闭的对象是否线程安全,如果没有明确说明,则后续维护很容易使得对象逸出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public int loadTheArk(Collection<Animal> candidates){
SortedSet<Animal> animals;
int numPairs = 0;
Animal candidate = null;

animals = new TreeSet<Animal>(new SpeciesGenderComparator());
animals.addAll(candidates);
for(Animal a : animals){
if(candidate == null || !candidate.isPotentialMate(a)){
candidate = a;
}else{
ark.load(new AnimalPair(candidate, a));
++numPairs;
candidate = null;
}
}
return numPairs;
}

ThreadLocal:使得线程中的某个值与保存值的对象关联起来。

  • ThreadLocal线程封闭:特别好的封闭方法。
    • 内部维护了一个map,key是线程名称,值是对象
    • 更规范的方式,允许将每个线程与持有数值的对象关联在一起。ThradLocal提供了get和set,为每个使用它的线程维护一份单独的拷贝,所以get总是返回当前执行线程通过set设置的最新值。
  • 通常用于防止对可变的单实例变量或者全局变量进行共享。

ThreadLocal提供了线程本地的实例。它与普通变量的区别在于,每个使用该变量的线程都会初始化一个完全独立的实例副本。ThreadLocal 变量通常被private static修饰。当一个线程结束时,它所使用的所有 ThreadLocal 相对的实例副本都可被回收。

总的来说,ThreadLocal 适用于每个线程需要自己独立的实例且该实例需要在多个方法中被使用,也即变量在线程间隔离而在方法或类间共享的场景。可以使用 java.lang.ThreadLocal 类来实现线程本地存储功能。

应用

  • 当某个频繁执行的操作需要一个临时对象,例如缓冲区,而同时又希望避免在每次执行时都重新分配该临时对象,就可以使用该技术。
  • 如果将单线程的应用程序移植到多线程中,通过将共享的全局变量转换为Th’readLoacl对象(如果全局变量的语义允许),可以维持线程安全性。
  • 例如JDBC的全局连接不是线程安全的,因此将其保存到ThreadLocal当中,每个线程拥有属于自己的副本。

缺陷

ThreadLocal会降低代码的可重用性,并在类间引入隐含的耦合性。

示例

对于以下代码,thread1 中设置 threadLocal 为 1,而 thread2 设置 threadLocal 为 2。过了一段时间之后,thread1 读取 threadLocal 依然是 1,不受 thread2 的影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ThreadLocalExample {
public static void main(String[] args) {
ThreadLocal threadLocal = new ThreadLocal();
Thread thread1 = new Thread(() -> {
threadLocal.set(1);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(threadLocal.get());
threadLocal.remove();
});
Thread thread2 = new Thread(() -> {
threadLocal.set(2);
threadLocal.remove();
});
thread1.start();
thread2.start();
}
}
1

不变性

不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致的状态。多线程环境下,应当尽量使对象成为不可变,来满足线程安全。

在不可变对象的内部仍可以使用可变对象来管理它们的状态。但这些可变对象对外而言是不可变的。

除非需要更高的可见性,否则应将所有的域都声明为私有域

除非需要某个域是可变的,否则应将其声明为final域

不可变的类型

  • final 关键字修饰的基本数据类型
  • String
  • 枚举类型
  • Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。

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

  • 对象创建以后状态就不能修改
    • 将类声明为final
  • 对象所有域都是final类型
    • 所有域声明为私有
    • 不设置set方法
    • 将所有可变数据声明为final
  • 对象是正确创建的,this引用没有逸出
    • 通过构造器初始化所有成员
    • 在get方法不直接返回对象本身,而是返回一个clone

final关键字:类、方法、变量

final确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无须同步。

  • 修饰类:
    • 不能被继承
    • 所有成员方法会隐式选择为final
  • 修饰方法
    • 锁定方法不能被继承修改
  • 修饰变量
    • 基本数据类型变量
    • 引用类型变量(初始化后,不能指向另一个对象)

其他创建不可变对象方法

  • 对于集合类型,Collections.unmodifiableXXX:Collection、List、Set、Map…
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ImmutableExample2 {

private static Map<Integer,Integer> map = Maps.newHashMap();

static {
map.put(1,2);
map.put(3,4);
map.put(5,6);
//创建final的map
map = Collections.unmodifiableMap(map);
}

public static void main(String[] args) {
//会抛出异常, map无法被修改
map.put(1,3);
log.info("{}",map.get(1));
}
}

将返回一个新的map,将数据拷贝过去,然后将所有更改数据转换为了抛出异常

1
2
3
public static <K,V> Map<K,V> unmodifiableMap(Map<? extends K, ? extends V> m) {
return new UnmodifiableMap<>(m);
}
  • Guava:ImmutableXXX:Collection、List、Set、Map…
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class ImmutableExample3 {

private final static ImmutableList<Integer> list = ImmutableList.of(1,2,3);

private final static ImmutableSet set = ImmutableSet.copyOf(list);

private final static ImmutableMap<Integer,Integer> map = ImmutableMap.of(1,2,3,4);

private final static ImmutableMap<Integer,Integer> map2 = ImmutableMap.<Integer,Integer>builder()
.put(1,2).put(3,4).put(5,6).build();

public static void main(String[] args) {
// set.add(4);
// map2.put(1,4);
System.out.println(map2.get(3));
}
}

安全发布

当某些情况下希望在多个线程间共享对象,此时必须确保安全地进行共享。

不正确的发布

1
2
3
4
public Holder holder
public void initHolder(){
holder = new Holder(42);
}

上述代码会存在可见性问题,其他线程看到地Holder对象将处于不一致地状态,即使在该对象地构造函数中已经正确地构建了不变性条件,这种不正确的发布会使得其他线程看到尚未创建完成的对象。未被正确发布的对象存在两个问题:

  • 除了发布对象的线程外,其他线程可以看到的Holder域是一个失效值,因此将看到一个空引用或之前的旧值
  • 线程看到Holder引用的值是最新的,但Holder状态的值是失效的,即可能线程第一次读取域时得到失效值,再次读取这个域会得到一个更新值。

不可变对象与初始化安全性

JMM为不可对象的共享提供了一种特殊的初始化安全性保证。

即使某个对象的引用对其他线程是可见的,也并不意味着对象状态对于使用该对象的线程来说一定是可见的,因此为了确保对象状态能呈现一致性,则必须使用同步。而即使在发布不可变对象的引用时没有使用同步,也仍然可以安全地访问该对象。

任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步。

安全发布的常用模式

可变对象必须通过安全地方式来发布,意味着在发布和使用该对象的线程时都必须使用同步。要安全地发布一个对象,对象地引用以及对象地状态必须同时对其他线程可见,一个正确构造地对象可以通过以下方式来安全发布

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

在线程安全容器内部的同步意味着,在对象放入到例如Vector当中将满足最后一条要求。线程安全库中的容器类提供了以下的安全发布保证

  • 通过将一个键或值放入HashTable等可以安全地将它发布给任何从这些容器中访问它的线程
  • 通过将某个元素放入Vector等,可以将该元素安全地发布到任何从这些容器中访问该元素的线程
  • 通过将某个元素放入BlockingQueue等,可以将元素安全地发布到任何从这些队列中访问该元素地线程。

对于静态发布,静态的初始化器由JVM在类的初始化阶段执行,由于JVM内部存在同步机制,因此通过这种方式初始化的任何对象都可以被安全地发布。

1
public static Holder holder = new Holder(42)

事实不可变对象

如果对象在发布后不会被修改,那么对于其他在没有额外同步地情况下安全地访问这些对象的线程来说,安全发布是足够的。所有安全发布机制都能保证,当对象的引用对所有访问该对象的线程可见时,对象发布时的状态对于所有线程也将时可见的,并且如果对象的状态不会再改变,则足以确保任何访问都是安全的。

事实不可变对象:如果对象从技术上来看是可变的,但其状态再发布后不会再改变。

可变对象

如果对象再构造后可以修改,那么安全发布只能保证发布当时状态的可见性,对于可变对象,不仅在发布对象时需要使用同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。

要安全地共享可变对象,这些对象就必须被安全地发布,并且必须是线程安全地或者由某个锁保护起来的。对象的发布需求取决于它的可变性

  • 不可变对象可以通过任意机制来发布
  • 事实不可变对象必须通过安全方式来发布
  • 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来。

安全地共享对象

当获得对象的一个引用时,需要知道在这个引用上可以执行哪些操作。在使用它之前是否需要获得一个锁,是否可以修改它的状态,或者只能读取它。当发布一个对象时,必须明确地说明对象地访问方式。

发布策略

线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改

只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。

线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口进行访问而不需要进一步的同步

保护对象。被保护的对象只能通过持有特定的锁来访问,保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。

线程安全策略

首先安全发布对象

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

不可变

final

无同步方案

对于一些方法本身不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此由一些代码天生就是线程安全的。

  • 可重入代码。

    • 即纯代码。不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。如果一个方法,它的返回结果时可以预测的,即只要输入了相同的数据,就能返回相同的结果。那么就满足可重入的要求。
  • 线程封闭。包括堆栈封闭、ThreadLocal

互斥同步

锁等操作

非阻塞同步

由于硬件指令集的发展而出现。基于冲突检测的乐观并发策略,先进行操作,如果没有其他线程争用共享数据,那操作就成功了,如果共享数据有争用,则采用其他补偿措施(例如不断重试,直到成功)。

由于该方法大多实现不需要将线程挂起,即称为非阻塞同步。

  • 测试并设置 Test and Set
  • 获取并增加 Fetch and Increment
  • 交换 Swap
  • 比较并交换 Compare and Swap CAS
    • ABA问题,如果要解决ABA问题可以使用Atomic,但是可能互斥同步更高效一些。
  • 加载链接/条件存储 Load-Linked/Store-Conditional LL/SC

无同步方案

对于一些方法本身不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此由一些代码天生就是线程安全的。

即纯代码。不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。

如果一个方法,它的返回结果时可以预测的,即只要输入了相同的数据,就能返回相同的结果。那么就满足可重入的要求。

设计线程安全的类

在线程安全的程序中,虽然可以将程序的所有状态都保存在公有的静态域中,但与那些将状态封装起来的程序相比,这些程序的线程安全性更难以得到验证,并且在修改时也更难以始终保持其线程安全性。

通过使用封装,可以使得在不对整个程序进行分析的情况下就可以判断一个类是否是线程安全的。

在设计线程安全类的过程中,需要包含以下三个基本要素:

  • 找出构成对象状态的所有变量
    • 从对象的域开始,如果对象中所有的域都是基本类型的变量,那么这些域将构成对象的全部状态。
    • 如果在对象的域中引用了其他对象,那么该对象的状态将包含被引用对象的域
  • 找出约束状态变量的不变性条件
  • 建立对象状态的并发访问管理策略

同步策略定义了如何在不违背对象不变条件或后验条件的情况下对其状态的访问操作进行协同。同步策略规定了如何将不可变性、线程封闭与加锁机制等结合起来以维护线程的安全性,并且还规定了哪些变量由哪些锁来保护。

思想

收集同步需求

要确保类的安全性,就需要确保它的不变性条件不会在并发访问的情况下被破坏,这就需要对其状态进行推断。

对象与变量都有一个状态空间,即所有可能的取值。状态空间越小,就越容易判断对象的状态。final类型的域使用越多,就越能简化对象可能状态的分析过程。

状态迁移

  • 不可变条件
  • 后验条件

在许多类中定义了一些不可变条件,用于判断状态是有效的还是无效的。即一些对象的一些值不能为负数等限定条件。

在某些操作中还会包含一些后验条件来判断状态迁移是否有效,对于计数器Counter,如果当前状态为17,那么下一个有效状态只能为18。当下一个状态依赖当前状态时,整个操作必须是一个复合操作。而并非所有的操作都会在状态转换上增加限制,例如更新温度这个状态,其之前的值并不影响结果。

后验条件:对状态的值进行检验,如果不符合,则异常

状态迁移:一个对象的下一个状态源于当前状态。如果某些状态是非法的,则必须封装该状态下的状态变量,否则客户代码会将对象置于非法状态。如果一个操作的过程当中出现非法状态,则该操作必须是原子的

如果不了解对象的不变性条件与后验条件,那么就不能确保线程安全性。要满足在状态变量的有效值或状态转换上的各种约束条件,就需要借助于原子性于封装性。

由于不变性条件以及后验条件在状态及状态转换上施加了各种约束,因此就需要额外的同步与封装。

  • 如果某些状态是无效的,那么必须对底层的状态变量进行封装,否则客户代码可能使对象处于无效状态
  • 如果某个操作中存在无效的状态转换,那么该操作必须是原子的。

在类中也可能包含同时约束多个状态变量的不变性条件,而此时在执行任何访问相关变量的操作时,都必须持有保护这些变量的锁。

依赖状态的操作

类的不变性条件与后验条件约束了在对象上有哪些状态和状态转换时有效的。在某些对象的方法中含包含一些基于状态的先验条件,例如不能从空队列中删除一个元素。

如果在某个操作中包含有基于状态的先验条件,那么这个操作就称为依赖状态的操作

在单线程中,如果某个操作无法满足先验条件,那么只能失败。但是在并非程序中,先验条件可能会由于其他线程执行的操作而为真,在并发程序中要一直等到先验条件为真,然后再执行操作。

实现某个等待先验条件为真时才执行的操作的简单实现是通过现有的库的类。

状态的所有权

如果以某个对象为根节点构造一张对象图,那么该对象的状态将是对象图中所有对象包含的域的一个子集。为什么是一个子集?在凑够对象可以到达的所有域中,需要满足哪些条件才不属于对象状态的一部分。

在定义哪些变量将构成对象的状态时,只考虑对象拥有的数据。所有权在Java中并没有得到充分的体现,而是类设计的一个要素。在许多情况下,所有权与封装性总是相互关联的:对象封装它拥有的状态,反之也成立,即对它封装的状态拥有所有权。

状态变量的所有者将决定采用何种加锁协议来维持变量状态的完整性。所有权意味着控制权,如果发布了某个可变对象的引用,那么就不再拥有独占的控制权,最多是”共享控制权“。

对于从构造函数或者从方法中传递过来的对象,类通常并不拥有这些对象,除非这些方法是被专门设计为转移传递进来的对象的所有权(同步容器封装器的工厂方法)。

容器类通常表现出一种”所有权分离“的形式。容器类拥有其自身的状态,而客户代码则拥有容器中的各个对象的状态。例如SynchronizedList当中,List对象是线程安全的,即当使用get或add时是不需要使用同步的。但是使用保存在List当中的对象时,可能需要使用同步,这些对象由应用程序拥有,而List只是替应用程序保管它们。与所有共享对象一样,它们必须安全地被共享。因此为了线程安全,这些对象应该要么是线程安全的对象,要么是事实不可变的对象,或者是由锁来保护的对象。

实例封闭

如果某对象不是线程安全的,那么可以通过多种技术使其在多线程中安全地使用。可以确保该对象只能由单个线程访问,或者通过一个锁来保护对该对象的所有访问。

封装简化了线程安全类的实现过程,它提供了一种实例封闭机制,当一个对象被封装到另一个对象中时,能够访问被封装对象的所有的代码路径都是已知的。因此更易于对代码进行分析,通过将封闭机制与合适的加锁策略结合起来,可以确保以线程安全的方式来使用非线程安全的对象。

将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的

  • 封闭在类的一个实例中,例如作为类的一个私有成员
  • 封闭在某个作用域中,例如作为一个局部变量
  • 封闭在一个线程中,例如通过ThreadLocal

封闭机制更易于构造线程安全的类,因为当封闭类的状态时,在分析类的线程安全性时就无须检查整个程序。

线程安全实现方式(委托)

基于对象的组合实现线程安全。

当类中各个组件都已经是线程安全的,那么需要视情况而定是否需要额外增加一个线程安全层。

状态变量

单状态

当类中只有一个状态变量,则可以将该变量设置为Atomic,即该类的状态就是Atomic的状态,而Atomic是线程安全的,因此类是安全的,即将线程安全委托给了Atomic。

多状态

而当存在多个状态变量,只要这些变量是彼此独立的,即组合的类不会在其包含的多个状态变量上增加任何不变性条件。此时依然可以使用Atomic。

发布底层的状态变量

如果一个类是由多个独立且线程安全的状态变量组成,并且在所有的操作中都不包含无效状态转换,那么可以将线程安全性委托给底层的状态变量。

如果一个状态变量是线程安全的,并且没有任何不变性条件约束它的值,在变量的操作上也不存在任何不允许的状态转换,那么就可以安全地发布这个变量

扩展类

Java类库中包含很多基础模块类,我们应该进行重用,但是大部分时候现有的类只能支持大部分的操作,此时就需要在不破坏线程安全性的情况下添加应该新的操作。

  • 修改原始类,但通常无法做到
  • 扩展类,扩展方法比直接添加代码到类中更加脆弱,因为同步策略实现被分布到多个单独维护的代码中。

客户端加锁机制

将扩展代码放入应该辅助类当中,并且需要确保整个对象使用的是同一个锁,如果在类内部使用了Vector与synchronized,则会有两个锁的存在。

通过添加一个原子操作来扩展类是脆弱的,因为将类的加锁代码分布到了多个类中。然而客户端加锁更脆弱。客户端加锁将类C的加锁代码放入了与C完全无关的其他类中,

组合

创建一个新类A,持有一个List的引用,将它们组合起来。A通过将List对象的操作委托给底层的List实例来实现List的操作,同时添加一个原子操作,即使将List传递给客户端,也只能通过类A进行访问。

1565054929055

只要确保了List的唯一外部引用,则可以保证线程安全性。

同步容器

类别

  • ArrayList->Vector、Stack
  • HashMap->HashTable
  • Collections.synchronizedXXX(List、Set、Map)
    • collection的静态工厂创建

同步容器也未必线程安全

同步容器虽然保证了同一时刻只有一个线程可以访问,但是线程交替进行访问依然会出现问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Thread thread1 = new Thread() {
public void run() {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
}

};

Thread thread2 = new Thread() {
public void run()
//在size=10,i=9时刻,上面的线程将其删除,而此时读取则会出现异常
for (int i = 0; i < vector.size(); i++) {
vector.get(i);
}
}
};

thread1.start();
thread2.start();

Java实现

监视器模式

遵循Java监视器模式的对象会将堆详细的所有可变状态都封装起来,并由对象自己的内置锁来保护。

1
2
3
4
5
6
7
8
9
10
11
12
public final class Counter{
private long value=0;
public synchronized long getValue(){
return value;
}
public synchronized long increment(){
if(value == Long.MAX_VALUE){
throw new IllegalStateException();
}
return ++value;
}
}

即对类内所有的状态变量都需要通过Counter的方法执行,而且这些方法都是同步的。例如Vector等都使用了监视器模式。

Java监视器模式仅仅是一种编写代码的约定,对于任何一种锁现象,只要自始至终都使用该锁对象,都可以用来保护对象的状态。

总结

参考