您的位置:首页 > 编程语言 > Java开发

Java并发编程学习——《Java Concurrency in Practice》学习笔记 3.对象的共享

2017-11-27 18:12 579 查看

3.1 可见性

在没有同步的情况下,编译器、处理器以及运行时都可能对操作的执行顺序进行一些意想不到的调整。因为它们会对代码的执行顺序进行“重排序”。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论。

重排序

重排序指的是编译器和处理器为了优化程序性能而对指令序列进行重新排序的手段

重排序主要分为两类:编译器优化的重排序、指令级别并行的重排序和内存系统的重排序

CSDN-Java并发编程系列之三:重排序与顺序一致性

有一个简单的方式可以解决因重排序引发的问题:只要有数据在多个线程之间共享,就使用正确的同步。

*但是书中程序3-1的例子

完整代码 在测试中始终都表现出了正确的结果。

3.1.1 失效数据

在缺乏同步的程序中可能产生错误的一种情况是:失效数据。当读线程读取变量时,可能会得到一个已经失效的值。

这时需要对操作该变量的get和set等方法进行同步。注意get方法也是要同步的,不然调用get的线程仍然会看到失效值

3.1.2 非原子的64位操作

最低安全性 out-of-thin-airsafety

当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。

最低安全性适用于绝大多数变量,但不适用于非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变量

当变量被声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

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

不建议过度依赖volatile变量提供的可见性。如果在代码中依赖volatile变量来控制状态的可见性,通常比使用锁的代码更脆弱,也更难理解。

仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性时需要对可见性进行复杂的判断,就不要使用volatile变量。volatile变量的正确使用方式包括:

- 确保它们自身状态的可见性

- 确保它们引用对象的状态的可见性

- 标识一些重要的程序声明周期事件的发生

volatile的一种典型用法是:检查某个状态标记以判断是否退出循环。

volatile的语义不足以确保递增操作的原子性

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

适合使用volatile变量的场景

对变量的写入操作不依赖变量的当前值,或能确保只有单个线程更新变量的值

该变量不会与其他状态变量一起纳入不变形条件中

访问变量时不需要加锁

3.2 发布与逸出

发布 Publish

发布一个对象是指,使对象能够在当前作用域之外的代码中使用。

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

逸出 Escape

某个不应该被发布的对象被发布。

间接发布对象的情况

非私有域引用

当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。一般来说,如果一个已经发布的对象能够通过非私有的变量引用和方法调用到达其他的对象,那么这些对象也都会被发布。

外部方法

外部方法 Alien Method

对于类C来说,外部方法是指行为并不完全由C来规定的方法,包括其他类中定义的方法以及类C中可以被改写的方法(既不是private也不是final的方法)

当把一个对象传递给某个外部方法时,就相当于发布了这个对象。

当某个对象逸出后,必须假设有某个类或线程可能会误用该对象。

发布一个内部的类的实例

内部类包含了对其所在类的隐含引用。

安全的对象构造过程

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

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

如果想在构造函数中注册一个事件监听器或启动线程,可以使用一个私有的构造函数和一个公共的工厂方法 Factory Method,从而避免不正确的构造过程。

private final EventListener listener;

private SafeListener() {
listener = new EventListener() {
@SuppressWarnings("unused")
public void onEvent(Event e) {
doSomething(e);
}
};
}

protected void doSomething(Event e) {
// do something
}

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

return safe;
}


完整代码

3.3 线程封闭

线程封闭 Thread Confinement

当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭

它是实现线程安全性的最简单方式之一。当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的。

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

Java中无法强制将对象封闭在某个对象中。线程封闭式在程序设计中的一个考虑因素,必须在程序中实现。Java语言及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和ThreadLocal类,但即便如此,程序员仍然需要负责确保封闭在线程中的对象不会从线程逸出。

3.3.1 Ad-hoc 线程封闭

Ad-hoc 线程封闭

维护线程封闭性的职责完全由程序实现来承担

Ad-hoc线程封闭是非常脆弱的,因为没有任何一种语言特性,能将对象封闭到目标线程上。

由于Ad-hoc线程封闭技术的脆弱性,在程序中尽量少使用。

3.3.2 栈封闭

栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。也被称为线程内部使用或者线程局部使用。局部变量的固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。

栈封闭比Ad-hoc线程封闭更易于维护,也更加健壮。

3.3.3 ThreadLocal类

维持线程封闭的更规范方法是使用ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal类提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行前程在调用set时设置的最新值。

ThreadLocal对象通常用于防止对可变的Singleton或全局变量进行共享。

当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免在每次执行时都重新分配该临时对象,就可以使用这项技术。但除非这个操作的执行频率非常高,或者分配操作的开销非常大,否则这项技术不可能带来性能提升。在Java 5.0中,这种技术被一种更直接的方式替代,即在每次调用时分配一个新的缓冲区,对于像临时缓冲区这种简单的对象,该技术并没有什么性能优势。

当某个线程初次调用ThreadLocal.get方法时,就会调用initialValue来获取初始值。从概念上看,可以将ThreadLocal视为包含了Map

3.4 不变性

满足同步需求的另一种方法是使用 不可变对象。

线程安全性是不可变对象的固有属性之一,它们的不变性条件是由构造函数创建的,不可变对象一定是线程安全的

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

- 对象创建以后其状态就不能修改

- 对象的所有域都是final类型(从技术角度上看不需要这么做,比如String,String会将散列值的计算推迟到第一次调用hashCode时进行,并将计算得到的散列值缓存到非final类型的域中,但这种方式之所以可行,是因为这个域有一个非默认的值,并且在每次计算中都得到相同的结果,因为基于一个不可变的状态。但自己编写代码时不要这么做)

- 对象时正确创建的(在对象的创建期间,this引用没有逸出)

在不可变对象的内部仍可以使用可变对象来管理它们的状态。

public class ThreeStooges {

private final Set<String> stooges = new HashSet<>();

public ThreeStooges() {
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
}

public boolean isStooge(String name) {
return stooges.contains(stooges);
}

}


完整代码

3.4.1 Final域

final类型的域是不能修改的,但如果final域引用的对象是可变的,那么这些被引用的对象是可以修改的。在Java内存模型中,final域还能确保初始化过程的安全性,从而可以不受限制的访问不可变对象,并在共享这些对象时无需同步。

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

3.4.2 使用Volatile来发布不可变对象

对于在访问和更新多个相关变量时出现的竞争条件问题,可以通过将这些变量全部保存在一个不可变对象中来消除。

不可变容器类

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(lastFactors, lastFactors.length);
}
}

}


public class Client {

private volatile OneValueCache cache = new OneValueCache(null, null);

public void doSomething(BigInteger i) {
BigInteger[] factors = cache.getFactors(i);
if (factors == null) {
factors = factor(i);
cache = new OneValueCache(i, factors);
}
}

private BigInteger[] factor(BigInteger i) {
// do factor

return null;
}

}


完整代码

3.5 安全发布

在没有足够同步的情况下发布对象

public Holder holder;

public void initialize() {
holder = new Holder(42);
}


由于存在可见性问题,其他线程看到的Holder对象将处于不一致的状态,即便在该对象的构造函数中已经正确的构建了不变性条件。这种不正确的发布导致其他线程看到尚未创建完成的对象。

3.5.1 不正确的发布

在未被正确发布的对象中存在两个问题:

1. 除了发布对象的线程外,其他线程会看到失效的值,一个空引用或者之前的旧值。

2. 更糟糕的情况是,引用的值是最新的,但状态的值是失效的。

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

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

这种保证还将眼神到被正确创建对象中所有final类型的域。在没有额外同步的情况下,也可以安全的访问final类型的域。然而,如果final类型的域锁指向的是可变对象,那么在访问这些域锁指向的对象的状态时仍然需要同步。

3.5.3 安全发布的常用模式

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

- 在静态初始化函数中初始化一个对象引用

- 将对象的引用保存到volatile类型的域或者AtomicReference对象中

- 将对象的引用保存到某个正确构造对象的final类型域中

- 将对象的引用保存到一个由锁保护的域中

在线程安全容器内部的同步意味着,将满足上述最后一条需求。

线程安全库中的容器类提供了以下的安全发布保证:

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

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

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

类库中的其他数据传递机制(例如Future和Exchanger)同样能实现安全发布功能。

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

public static Holder holder = new Holder(42);


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

3.5.4 事实不可变对象

如果对象在发布后不会被修改,那么对于其他在没有额外同步的情况下安全的访问这些对象的线程来说,安全发布是足够的。

事实不可变对象 Effectively Immutable Object

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

在没有额外的同步的情况下,任何线程都可以安全的使用被安全发布的事实不可变对象。通过使用事实不可变对象,不仅可以简化开发过程,还能减少同步提高性能。

3.5.5 可变对象

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

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

- 不可变对象可以通过任意机制来发布

- 事实不可变对象必须通过安全方式来发布

- 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来

3.5.6 安全的共享对象

在并发程序中使用和共享对象时,可以使用一些实用的策略,包括

线程封闭

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

只读共享

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

线程安全共享

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

保护对象

被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象的对象,以及已发布的并且由某个特定锁保护的对象。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  java 并发