了解并发编程中对象的共享

目录

•写在前面

•可见性

volatile变量

•发布与逸出

•线程封闭

Ad-hoc线程

栈封闭

ThreadLocal类

•不变性

•安全发布


•写在前面

我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。如果没有同步,那么这种情况就无法实现,所以我们可以通过显示的同步或者类库中内置的同步来保证对象被安全的发布。

•可见性

可见性是一种复杂的属性,因为可见性的错误总是会违背我们的直觉,在单线程环境中,如果向某个变量先写入值,然后在没有其他的写入操作的情况下读取这个变量,那么总能得到相同的值。这看起来很自然,然而,当读操作和写操作在不同的线程中执行时,情况却并非如此,这听起来或许有些难以接受,通常,我们无法确保执行读操作的线程能在恰当的时候看到其他线程写入的值,有时甚至是根本不可能的事情,为了确保多线程之间对内存写入操作的可见性,必须使用同步机制。

在没有同步的情况下,编译器,处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确结论(可能你会说重排序这么麻烦,为啥还要重排序呢,关于这一点,可以去看看我的另一篇文章,JMM内存模型,因为这样子能最大限度的利用现代多核处理器的强大性能)。

当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值,这种安全性保证也被称为最低安全性。最低安全性适用于绝大多数变量,但是存在一个例外,非volatile类型的64位数值变量,java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位操作,当读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位。因此,即使不考虑失效数据问题,在多线程程序中使用共享且可变的long和double等类型的变量也是不安全的,除非用关键字volatile来声明它们,或者用锁保护起来。这里值得一提的是,其实在虚拟机规范中虽然允许64位的long和double可以分两步,但是现代大部分虚拟机实现,还是会将这两个实现成原子性操作。

内置锁可以用来确保某个线程以一种可预测的方式来查看另一个线程的执行结果。我们可以进一步理解为什么在访问某个共享且可变的变量时,要求所有线程在同一个锁上同步,就是为了确保某个线程写入该变量的值对于其他线程来说都是可见的,否则,如果一个线程在未持有正确锁的情况下读取某个变量,那么读到的可能是一个失效值。加锁的含义不仅仅局限于互斥行为,还包括内存可见性,为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。

•volatile变量

Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他的线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量时共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时,总会返回最新写入的值。使用volatile变量不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量时一种比synchronized关键字更轻量级的同步机制。

volatile变量对可见性的影响比volatile变量本身更为重要,当线程A首先写入一个volatile变量并且线程B随后读取该变量时,在写入volatile变量之前对A可见的所有变量的值,在B读取了volatile变量后,对B也是可见的,因此,从内存可见性的角度来看,写入volatile变量相当于退出同步代码块,而读取volatile变量就相当于进入同步代码块。不过我们并不建议过度依赖volatile变量提供的可见性,如果在代码中依赖volatile变量来控制状态的可见性,通常比使用锁的代码更脆弱,也更难理解。仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们,如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用volatile变量,volatile变量的正确使用方式包括:确保它们自身状态的可见性,确保它们所引用对象的状态的可见性,以及标识一些重要的程序生命周期事件的发生(例如初始化或关闭)。

值得一提的是,volatile的语义不足以确保递增操作(count++)的原子性,加锁机制即可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。当且仅当满足一下条件时,才应该使用volatile变量:

  • 对变量的写操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值;
  • 该变量不会与其他状态变量一起纳入不变性条件中;
  • 在访问变量时不需要加锁;

•发布与逸出

“发布”一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。例如,将一个指向该对象的引用保存到其他代码可以访问的地方,或者在某一个非私有的方法中返回该引用,或者将该引用传递到其他类的方法中。在许多情况中,我们要确保对象及其内部状态不被发布。而在某些情况下,我们需要发布某个对象,但如果在发布是要确保线程安全性,则可能需要同步。发布内部状态可能会破坏封装性,并使得程序难以维持不变性条件。例如,如果在对象构造完成之前就发布对象,就会破坏线程安全性。当某个不应该发布的对象被发布时,这种情况就被成为“逸出”。封装的最重要原因就是,封装能够使得对程序的正确性进行分析变得可能,并使得无意中破坏设计约束条件变得更难。注意了,不要在构造过程中使this引用逸出,即时最后一行也不行。

•线程封闭

当访问共享的可变数据时,通常需要使用同步,一种避免使用同步的方式就是不共享数据,如果仅在单线程内访问数据,就不需要同步,这种技术就称为线程封闭,它是实现线程安全性的最简单的方式之一,当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的。线程封闭技术的一种常见应用是JDBC对象,JDBC规范并不要求Connection对象必须是线程安全的。

Ad-hoc线程

这里我们要提到Ad-hoc线程,Ad-hoc线程封闭是指,维护线程封闭性的职责完全由程序实现来承担,Ad-hoc线程封闭是非常脆弱的,因为没有任何一种语言特性,例如可见性修饰符或局部变量,能将对象封闭到目标线程上。事实上,对线程封闭对象的引用通常保存在公有变量中。当决定使用线程封闭技术时,通常是因为要将某个特定的子系统实现为一个单线程子系统。在某些情况下,单线程子系统提供的简便性要胜过Ad-hoc线程封闭技术的脆弱性。由于Ad-hoc线程封闭技术的脆弱性,因此在线程中尽量少用它,在可能的情况下,应该使用更强的线程封闭技术,如下面要讲到的栈封闭。

栈封闭

栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。正如封装能试的代码更容易维持不变性条件那样,同步变量也能使对象更易于封闭在线程中,局部变量的固有属性之一就是封闭在执行线程中。他们位于执行线程的栈中,其他线程无法访问这个栈,栈封闭比Ad-hoc线程封闭更易于维护,也更加健壮。在维持对象引用的栈封闭时,程序员需要多做一些工作以确保被引用的对象不会溢出。

ThreadLocal类

维持线程封闭性的一种更规范的方法是使用ThreadLocal类,这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是放回由当前执行线程在调用set时设置的最新值。ThreadLocal对象通常用于防止对可变的单实例变量或全局变量进行共享。当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免在每次执行时都重新分配该临时对象,就可以使用这项技术。

•不变性

满足同步需求的另一种方法是使用不可变对象,如果对象的状态不可改变,那么这些问题与复杂性也就自然消失了。如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象,线程安全性是不可变对象的固有属性之一,他们的不变性是由构造函数创建的,只要它们的状态不改变,那么这些不变性条件就能得以维持。注意了,不可变对象一定是线程安全的。不可变性并不等于将对象中所有的域都声明为final类型,即使对象所有的域都是final类型的,这个对象也仍然是可变的,因为final类型的域中可以保存对可变对象的引用。满足以下条件时,对象才是不可变的。

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

关键字final可以视为C++中const机制的一种受限版本,用于构造不可变性对象,final类型的域是不能修改的(但如果final域引用的对象是可变的,那么这些被引用的对象是可以修改的)。然而,在java内存模型中 ,final域还有这特殊的语义,final域能确保初始化过程的安全性,从而可以不受限制的访问不可变对象,并在共享这些对象时无须同步。通过将域声明为final类型,也相当于告诉维护人员这些域不会变化的。正如“除非要更高的可见性,否则应该将所有的域都声明为私有域”是一个良好的变成习惯,“除非需要某个域是可变的,否则应将其声明为final域”也是一个良好的变成习惯。

•安全发布

我们前面讨论的是如何确保对象不被发布,例如让对象封闭在线程或另一个对象的内部,当然,在某些情况下我们希望在多个线程间共享对象,此时必须确保安全地进行共享。因为不可变对象是一种非常重要的对象,因此Java内存模型为不可变对象的共享提供了一种特殊的初始化安全性保证。我们已经知道,即使某个对象的引用对其他线程是可见的,也并不意味着对象状态对于使用该对象的线程来说一定是可见的。为了确保对象状态能呈现出一致的视图,就必须使用同步。另一方面,即使在发布不可变对象的引用没有使用同步,也仍然可以安全的访问该对象,为了维持这种初始化安全性的保证,必须满足不可变性的所有需求:状态不可修改,所有域都是final类型,以及正确的构造过程。任何线程都可以在不需要额外同步的情况下安全点访问不可变对象,即使在发布这些对象时没有使用同步。

要安全的发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全的发布:

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

如果对象从技术上来看是可变的,但其状态在发布后不会再改变,那么把这种对象称为“事实不可变对象”。这些对象发布后,程序只需将它们视为不可变对象即可,通过使用事实不可变对象,不仅可以简化开发过程,而且还能由于减少了同步而提高性能。在没有额外的同步情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。对象的发布需求取决于它的可变性

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

当获得对象的一个引用时,你需要知道在这个引用上可以执行哪些操作,在使用它之前是否需要获得一个锁?是否可以修改他的状态,或者只能读取它?许多并发错误都是由于没有理解共享对象的这些“既定规则”而导致的。当发布一个对象时,必须明确的说明对象的访问方式。

发布了78 篇原创文章 · 获赞 440 · 访问量 73万+

猜你喜欢

转载自blog.csdn.net/DBC_121/article/details/103755282
今日推荐