博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
第3章 对象的共享
阅读量:6566 次
发布时间:2019-06-24

本文共 10660 字,大约阅读时间需要 35 分钟。

hot3.png

我们已经知道了同步代码块和同步方法可以确保以原子的方式执行操作,但一种常见的误解是,认为关键字synchronized只能用于实现原子性或者确定“临界区”。同步还有另一个重要的方面:内存可见性。我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。如果没有同步,那么这种情况就无法实现。你可以通过显示的同步或者类库中内置的同步来保证对象被安全地发布。 ##3.1 可见性## 通常,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。在没有同步的情况下,编译器,处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整(重排序)。在缺乏足够同步的多线程程序中,要想对内存操作的顺序进行判断,几乎无法得出正确的结论。

幸运的是,有一种简单的方法能避免这些复杂的问题:只要有数据在多个线程之间共享,就使用正确的同步。

注意:重排序,这看上去似乎是一种很失败的设计,但却能使JVM充分地利用现代多核处理器的强大性能。例如,在缺少同步的情况下,Java内存模型允许编译器对操作顺序进行重排序,并将数值缓存在寄存器中。此外,它还允许CPU对操作顺序进行重排序,并将数值缓存在处理器特定的缓存中。

###3.1.1 失效数据### 除非在每次访问变量时都使用同步,否则很可能获得该变量的一个失效值。更糟糕的是,失效值可能不会同时出现:一个线程可能获得某个变量的最新值,而另一个获得变量的失效值。 ###3.1.2 非原子的64位操作### 当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性。

最低安全性适用于绝大多数变量,但是存在一个例外:非volatile类型的64位数值变量(double和long)。Java内存模型要求,变量的读取操作和写入操作都必须是原子操作。但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。当读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位。

因此,即时不考虑失效数据问题,在多线程中使用共享且可变的long和double等类型的变量也是不安全的,除非用关键字volatile来声明它们,或者用锁保护起来。 ###3.1.3 加锁与可见性### 内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。为什么在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,就是为了确保某个线程写入该变量的值对于其他线程来说都是可见的。否则,如果一个线程在未持有正确锁的情况下读取某个变量,那么读到的可能是一个失效值。

加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。

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

在访问volatile变量时不会执行加锁操作,因此也就不会是执行线程阻塞,因此,volatile变量是一个中比synchronized关键字更轻量级的同步机制。

仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用volatile变量。volatile变量的正确使用方式包括:确保它们自身状态的可见性,确保它们所引用对象的状态的可见性,以及标示一些重要的程序生命周期事件的发生。所以,volatile变量通常用做某个操作完成,发生中断或者状态的标志。尽管volatile变量也可以用于表示其他的状态信息,但在使用时要非常小心。例如,volatile的语义不足以确保递增操作(count++)的原子操作,除非你能确保只有一个线程对变量执行写操作。

加锁机制既可以确保可见性有可以确保原子性,而volatile变量只能确保可见性。

当且仅当满足以下所有条件时,才应该使用volatile变量:

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

##3.2 发布与逸出## “发布”一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。例如:将一个指向该对象的引用保存到其他代码可以访问的地方,或者在某一个非私有的方法中返回该引用,或者将引用传递到其他类的方法中。发布内部状态可能会破坏封装性,并使得程序难以维持不变性条件。当某个不应该发布的对象被发布时,这种情况被称为“逸出”。

一种发布对象或其内部状态机制的实例就是发布一个内部类,代码如下:

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

以上代码构造过程中,不要这么使用,因为在通过source对象发布EventListener时,也隐含地发布了ThisEscape实例本身(this),在其构建内部类的实例中包含了对ThisEscape实例的隐含引用。

那么怎么才能安全的构造一个对象呢?

安全的对象构造过程 当且仅当对象的构造函数返回时,对象才处于可预测的和一致的状态。因此,当从对象的构造函数中发布对象时,只是发布了一个尚未构造完成的对象。即使发布对象的语句位于构造函数的最后一行也是如此。如果this引用在构造过程中逸出,那么这种对象就被认为是不正确构造。

不要在构造过程中使this引用逸出。

在构造过程中使this引用逸出的一个常见错误是,在构造函数中启动一个线程。当对象在其构造函数中创建一个线程时,无论是显示创建(通过将它传递给构造函数)还是隐式创建(由于Thread或Runnable是该对象的一个内部类),this引用都会被新创建的线程共享。在对象尚未完全构造之前,新的线程就可以看见它。在构造函数中创建线程并没有错误,但最好不要立即启动它,而是通过一个start()方法来启动。

在构造过程中调用一个可改写的实例方法时(既不时私有方法,也不是终结方法),同样会导致this引用在构造过程中逸出。

public class SafeListener {    private final EventListener listener;    private SafeListener() {        listener = new EventListener() {            public void onEvent(Event e) {                doSomething(e);            }        }    }    public static SafeListener newInstance(EventSource source) {        SafeListener safe = new SafeListener();        source.registerListener(safe.listener);        return safe;    }}

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

线程封闭技术的另一种常见应用是JDBC的Connection对象。JDBC规范并不要求Connection对象必须是线程安全的。在典型的服务器应用程序中,线程从连接池中获得一个Connection对象,并且用该对象来处理请求,使用完后再将对象返还给连接池。由于大多数请求都是由单个线程采用同步的方式来处理,并且在Connection对象返回之前,连接池不会再将它分配给其他线程,因此,这种连接管理模式在处理请求时隐含地将Connection对象封闭在线程中。

线程封闭是在程序设计中的一个考虑因素,必须在程序中实现。Java语言及其核心库提供了一些机制来帮助维持线程封闭性,例如:局部变量和ThreadLocal类。但即便如此,程序员仍然需要负责确保封闭在线程中的对象不会从线程中逸出。 ###3.3.1 Ad-hoc 线程封闭### Ad-hoc 线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。Ad-hoc 线程封闭是非常脆弱的,因为没有任何一种语言特性,例如:可见性修饰符或局部变量,能将对象封闭到目标线程上。事实上,对线程封闭对象的引用通常保存在公有变量中。

###3.3.2 栈封闭### 栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。局部变量的固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。栈封闭比Ad-hoc线程封闭更易于维护,也更加健壮。

对于基本类型的局部变量,无论如何都不会破坏栈封闭性。由于任何方法都无法获得对基本类型的引用,因此Java语言的这种语义就确保了基本类型的局部变量始终封闭在线程内。

如果在线程内部上下文中使用非线程安全的对象,那么该对象仍然是线程安全的。然而,要小心的是,只有编写代码的开发人员才知道哪些对象需要被封闭到执行线程中,以及被封闭的对象是否是线程安全的。 ###3.3.3 ThreadLocal类### 维持线程封闭性的一种更规范方法是使用ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal对象通常用于防止对可变的单实例变量或全局变量进行共享。

由于JDBC的连接对象不一定是线程安全的,因此,当多线程应用程序在没有协同的情况下使用全局变量时,就不是线程安全的。通过将JDBC的连接保存到ThreadLocal对象中,每个线程都拥有属于自己的连接。

当某个线程初次调用ThreadLocal.get方法时,就会调用initialVal来获取初始值。从概念上看,你可以将ThreadLocal<T>视为包含了Map<Thread, T>对象,其中保存了特定于该线程的值,但ThreadLocal的实现并非如此。这些特定于线程的值保存在Thread对象中,当线程终止后,这些值会作为垃圾回收。

ThreadLocal变量类似于全局变量,它能降低代码的可重用性,并在类之间引入隐含的耦合性,因此在使用时要格外小心。 ##3.4 不变性## 满足同步需求的另一种方法是使用不可变对象。如果对象的状态不会改变,那么这些问题与复杂性也就自然消失了。

如果某个对象在被创建后其状态就不能修改,那么这个对象就称为不可变对象。线程安全性是不可变对象的固有属性之一,它们的不变性条件是有构造函数创建的,只要它们的状态不改变,那么这些不变性条件就能得以维持。

不可变对象一定是安全的。

那么,当满足以下条件时,对象才是不可变的:

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

从技术上来说,不可变对象并不需要将其所有的域都声明为final类型,例如String就是这种情况,这就要对类的良性数据竞争情况做精确分析,因此需要深入理解Java内存模型(注意:String会将散列值的计算推迟到第一次调用hashCode时进行,并将计算得到的散列值缓存到非final类型的域中,但这种方式之所以可行,是因为这个域有一个非默认的值,并且在每次计算中都得到相同的结果,因为基于一个不可变的状态,自己在编写代码时不要这么做)。

在不可变对象基础上构建的不可变类,代码如下:

public final class ThreeStooges {    private final Set
stooges = new HashSet
(); public ThreeStooges() { stooges.add("Moe"); stooges.add("Larry"); stooges.add("Curly"); } public boolean isStooge(String name) { return stooges.contains(name) }}

由于程序状态总在不断地变化,可能会认为需要使用不可变对象的地方不多,但实际情况并非如此。在“不可变的对象”与“不可变的对象引用”之间存在差异。保存在不可变对象中的程序状态仍然可以更新,即通过将一个保存新状态的实例来“替换”原有的不可变大对象。

许多人担心这种方式会带来性能问题,但这是没必要的。内存分配的开销比你想象的还要低,并且不可变对象还会带来其他优势,例如:减少了对加锁或者保护性副本的需求,以及降低对基于“代”的垃圾收集机制的影响。

###3.4.1 Final域### final类型的域是不能修改的(但如果final域所引用的对象是可变的,那么这些被引用的对象是可以修改的)。然而,在Java内存模型中,final域还有着特殊的语义。final域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无须同步。

正如“除非需要更高的可见性,否则应将所有的域都声明为私有域”是一个良好的编程习惯,“除非需要某个域是可变的,否则应将其声明为final域”也是一个良好的编程习惯。

###3.4.2 示例:使用Volatile类型来发布不可变对象### 对于在访问和更新多个相关变量时出现的竞争条件问题,可以通过将这些变量全部保存在一个不可变对象中消除。如果是一个可变的对象,那么必须使用锁来确保原子性。如果是一个不可变的对象,那么当线程获得了对该对象的引用后,就不必担心另一个线程会修改对象的状态。如果要更新这些变量,那么可以创建一个新的容器对象,但其他使用原有对象的线程仍然会看到对象处于一致的状态。在某些情况下,不可变对象能提供一种弱形式的原子性。

  1. 将数据缓存在不可变容器类,代码如下:
public class OneValueCache {        private final BigInteger lastNumber;        private final BigInteger[] lastFactors;            public OneValueCache(BigInteger i, BigInteger[] factors) {            lastNumber = i;            lastFactors = Arrays.copyOf(factors, factors.length);        }            public BigInteger[] getFactors(BigInteger i) {            if (lastNumber == null || !lastNumber.equals(i)) {                return null;            } else {                return Arrays.copyOf(factors, factors.length);            }        }}
  1. VolatileCachedFactorizer使用了OneValueCache来保存缓存的数据。当一个线程将volatile类型的cache设置为引用一个新的OneValueCache时,其他线程就会立即看到新缓存的数据。代码如下:
public class VolatileCachedFactorizer implements Servlet {        // volatile确保可见性        private volatile OneValueCache cache = new OneValueCache(null, null);                public void service() {            BigInteger i = extractFromRequest(req);            BigInteger[] factors = cache.getFactors(i);            if (factors == null) {                factors = factor(i);                cache = new OneValueCache(i, factors);            }            encodeIntoResponse(resp, factors);        }}

与cache相关的操作不会相互干扰,因为OneValueCache是不可变的,并且在每条相应的代码路径中只会访问它一次。通过使用包含多个状态变量的容器对象来维持不变性条件,并使用一个volatile类型的引用来确保可见性,使得VolatileCachedFactorizer在没有显式地使用锁的情况下仍然是线程安全的。

##3.5 安全发布## 在没有足够同步的情况下发布对象(不要这么做),代码如下:

// 不安全发布public Holder holder;public void initialize() {    holder = new Holder(42);}

以上代码,由于存在可见性问题,其他线程看到的Holder对象将处于不一致的状态,即便在该对象的构造函数中已经正确地构建了不变性条件。这种不正确的发布导致其他线程看到尚未创建完成的对象。 ###3.5.1 不正确的发布:正确的对象被破坏### 由于上面的代码,对象未被正确发布,因此这个类可能出现故障,在调用assertSanity()方法时将抛出AssertionError异常[1],代码如下:

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");        }    }}

由于没有使用同步来确保Holder对象对其他线程可见,因此将Holder称为“未被正确发布”。在未被正确发布的对象中存在两个问题。首先,除了发布对象的线程外,其他线程可能看到的Holder域是一个失效值,因此将看到一个空引用或者之前的旧值。然而,更糟糕的情况是,线程看到Holder引用的值是最新的,但Holder状态的值却是失效的[2]。情况变得更加不可预测的是,某个线程在第一次读取域时得到失效值,而再次读取这个域时会的到一个更新值。

如果没有足够的同步,那么当在多个线程间共享数据时将发生一些非常奇怪的事情。

[1] 问题并不在于Holder类本身,而是在于Holder类未被正确地发布。然而,如果将n声明为final类型,那么Holder将不可变,从而避免出现不正确发布的问题。 [2] 尽管在构造函数中设置的域值似乎是第一次向这些域中写入的值,因此不会有“更旧的”值被视为失效值,但Object的构造函数会在子类构造函数运行之前先将默认值写入所有的域。因此,某个域的默认值可能被视为失效值。

###3.5.2 不可变对象与初始化安全性### 由于不可变对象是一种非常重要的对象,因此Java内存模型为不可变对象的共享提供了一种特殊的初始化安全性保证。我们已经知道,即使某个对象的引用对其它线程是可见的,也并不意味着对象状态对于使用该对象的线程来说一定是可见的。为了确保对象状态能呈现出一致的视图,就必须使用同步。

另一方面,即使在发布不可变对象的引用时没有使用同步,也仍然可以安全地访问该对象。为了维持这种初始化安全性的保证,必须满足不可变性的所有需求:状态不可修改,所有域都是final类型,以及正确的构造过程。

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

这种保证还将延伸到被正确创建对象中所有final类型的域。在没有额外同步的情况下,也可以安全地访问final类型的域。然而,如果final类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态时仍然需要同步。 ###3.5.3 安全发布的常用模式### 可变对象必须通过安全的方式来发布,这通常意味着在发布和使用该对象的线程时都必须使用同步。要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:

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

通过将一个键或者值放入Hashtable,synchronizedMap或者ConcurrentHashMap中,可以安全地将它发布给任何从这些容器中访问它的线程(无论是直接访问还是通过迭代器访问)。

通过将某个元素放入Vector,CopyOnWriteArrayList,CopyOnWriteArraySet,synchronizedList或者synchronizedSet中,可以将该元素安全地发布到任何从这容器中访问该元素的线程。

通过将某个元素放入BlockingQueue或者ConcurrentLinkedQueue中,可以将该元素安全地发布到任何从这些队列中访问该元素的线程。

通常,要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器:

public static Holder holder = new Holder(42);

静态初始化器由JVM在类的初始化阶段执行。由于JVM内部存在着同步机制,因此通过这种方式初始化的任何对象都可以安全地发布。 ###3.5.4 事实不可变对象### 如果对象在发布后不会被修改,那么对于其他在没有额外同步的情况下安全地访问这些对象的线程来说,安全发布是足够的。所有的安全发布机制都能确保,当对象的引用对所有访问该对象的线程可见时,对象发布时的状态对于所有线程也将是可见的,并且如果对象状态不会再改变,那么足以确保任何访问都是安全的。

如果对象从技术上来看是可变的,但其状态在发布后不会再改变,那么把这种对象称为“事实不可变对象”。

在没有额外的同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。

###3.5.5 可变对象### 如果对象在构造后可以修改,那么安全发布只能确保“发布当时”状态的可见性。对于可变对象,不仅在发布对象时需要使用同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。要安全地共享可变对象,这些对象就必须被安全地发布,并且必须是线程安全的或者由某个锁保护起来。

对象的发布需求取决于它的可变性:

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

###3.5.6 安全地共享对象### 在并发程序中使用和共享对象时,可以使用一些实用同步策略,包括:

  1. 线程封闭:线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改;
  2. 只读共享:在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象;
  3. 线程安全共享:线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步;
  4. 保护对象:被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象;

转载于:https://my.oschina.net/xianggao/blog/408950

你可能感兴趣的文章
【EASYDOM系列教程】之 DOM 元素树
查看>>
# vue.js 之 对vue.js基础理解
查看>>
webpack源码阅读——npm脚本运行webpack与命令行输入webpack的区别
查看>>
Visual Studio 20年
查看>>
js 上下拖动显示ajax数据(自适应手机版)
查看>>
java的观察者模式
查看>>
Express.js 中的 Sessions 如何工作?(译)
查看>>
ionic之点击放大图片
查看>>
前端计划——面试题总结-CSS篇
查看>>
慕课网_《微信授权登录》学习总结
查看>>
[elixir! #0043] 精确到 1bit 的字符串处理
查看>>
简单快速的开发WEB应用, PHP 框架 Lemon 介绍
查看>>
Web自动化之Headless Chrome概览
查看>>
【133天】尚学堂高淇Java300集视频精华笔记(71-72)
查看>>
剖析 Laravel 计划任务--事件属性
查看>>
通过setTimeout处理click,dblclick,mousedown等事件的冲突
查看>>
实体服务是一种反模式
查看>>
《Git in Practice》作者访谈:关于Git的八个问题
查看>>
SRE工程师到底是做什么的?
查看>>
腾讯信鸽海量移动推送服务是如何构建的
查看>>