学习内容
一、共享引发的问题
1.1、可见性
1.2、原子性
1.3、解决方案
加锁、volatile
二、发布与逸出
2.2、不安全的发布
2.3、安全发布
不可变对象、事实不可变对象、可变对象
安全发布的常用模式
安全地共享对象
2.4、逸出
this益处案例
三、解决共享对象安全问题
3.1、线程封闭
3.2、栈封闭
3.3、ThreadLocal类
3.4、不变性
3.5、Final域
如何安全的共享和发布对象,从而使多个线程同时访问共享对象时依然能够表现出正确的行为?
如果不能正确的共享和发布会带来如下问题。
一、共享引发的问题
1.1、可见性
失效数据:
可见性就是当一个线程修改了共享变量时,另一个线程可以读取到这个修改后的值。但是由于多处理器下各个CPU拥有自己的缓存,所以对象被改写时,其他线程获取到的不一定是最新值,所以存在失效数据问题。
1.2、原子性
32位操作系统上对64位变量读写的非原子性。简单的说,在32位操作系统上,对64位的数据的读写是分两步的,一步取前32位数据,一步取后32位数据,通过这两步操作来实现对64位数据的读写。但是这样的非原子性操作会有问题。因为,假如在读或写一个64位的高32位的数据的时候,另外一个线程写了该64位数据的低32位,这样就会使得获取的64位数据是失效数据。如果想要避免这种情况,就用关键字volatile声明64位的变量,或者把对他们的读写操作锁起来。
1.3解决方案
一:锁
加锁或者使用volatile达到可见性和原子性。锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。对于同一个锁,后面进入锁的线程可以看到之前线程在锁中的所有操作结果。
加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步
二:volatile
volatile 关键字也可以解决可见性问题,但是注意,除了修饰double、float变量可以解决原子性问题外,其他原子性问题不行。具体如何实现先简单介绍一些相关原理名词概念。
1、内存屏障(Memory Barriers)
处理器的一组指令,用于实现对内存操作的顺序限制。
2、缓冲行
CPU告诉缓存中可以分配的最小存储单位,处理器填写缓存行时,会加载整个缓存行。
3、Lock前缀的指令
Lock前缀的指令在多核处理器下会发生两件事情:
1)将当前处理器的缓存行的数据协会到系统内存。
2)这个写回内存的操作会使其他CPU缓存了该内存的地址的数据无效。
4、缓存一致性协议
在多处理器下,为零保证各个处理器的缓存是一致的,每个处理器都会通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了。当处理器发现自己缓存行对应的地址被修改,就会将当前处理器的缓存行设置为无效状态。当处理器对这个数据进行读写的时候,会重新把数据从内存中读取到处理器缓存中。
5、CAS
CompareAndSwap 比较并交换,CAS操作需要输入两个值,一个旧值(执行CAS操作前的值,期望值)和一个新值,只有当当前值等于旧值时,才可以将当前值设置为新值,否则不设置。这是一个原子操作,由硬件保证。
6、重排序规则
从根本上来所,JMM 对编译器和处理器的重排序限制只有一条,只要不改变程序执行的结果(指的是单线程或者正确同步的多线程环境下),那么编译器和处理器怎么优化都可以。
7、valatile变量被写入时,加了一个Lock前缀的指定,以此来达到可见性的目的。
二、发布与逸出
发布(publishing):
发布一个对象的意思是使它能够被当前范围之外的代码所使用。比如将一个引用存储到其他代码可以访问的地方,在一个非私有的方法中返回这个引用,也可以把它传递到其他类的方法中。
逸出(escape):
一个对象在尚未准备好时就将它发布,这种情况称作逸出。
2.1、不安全的发布
在某些情况下我们不得不多个线程间共享对象,此时必须确保安全地进行共享。
如下:在没有足够同步的情况下发布对象
public class UnsafePublish {
private String[] states = {"a","b","c"};
//通过类的非私有方法返回类的引用
public String[] getStates(){
return states;
}
public static void main(String[] args) {
UnsafePublish unsafePublish = new UnsafePublish();
log.info("{}",Arrays.toString(unsafePublish.getStates()));
//尝试对私有属性的数组进行修改
unsafePublish.getStates()[0] = "d";
log.info("{}",Arrays.toString(unsafePublish.getStates()));
}
}
输出结果:
[a,b,c]
[d,b,c]
代码中,声明了一个String类型的数组并通过一个public方法getState()发布了这个数组(域),在这个类的任何外部线程都可以访问这个域。
在这个例子中,通过UnsafePublish unsafePublish = new UnsafePublish();发布了这个类的一个实例,并使用unsafePublish.getStates()[0],得到了这个私有域,并将第一个元素赋为"d",所以第二次输出为[d,b,c],这样是线程不安全的。
2.2、安全发布
不可变对象、事实不可变对象、可变对象
不可变对象
由于不可变对象是一种非常重要的对象,因此Java 内存模型为不可变对象的共享提供了一种特殊的初始化安全性保障。
任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步。
事实不可变对象
如果对象在发布后不会被修改,那么 程序只需将它们视为不可变对象即可。在没有额外的同步情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。
例如,Date 本身是可变的,但如果将它作为不可变对象来使用,那么在多个线程之间共享 Date 对象时,就可以省去对锁的使用。假设需要维护一个 Map 对象,其中保存了每位用户的最近登录时间:
public Map<String, Date> lastLogin = Collections.synchronizedMap(new HashMap<String, Date>());
如果Date对象的值在被放入Map 后就不会改变,那么 synchronizedMap 中的同步机制就足以使 Date 值被安全地发布,并且在访问这些 Date 值时不需要额外的同步。
可变对象
对于可变对象,不仅在发布对象时需要使用同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。
对象的发布需要取决于它的可变性:
不可变对象可以通过任何机制来发布
事实不可变对象必须通过安全方式来发布。
可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来。
全发布的常用模式
要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:
在静态初始化函数中初始化一个对象引用。
将对象的引用保存到 volatile 类型的域或者 AtomicReferance 对象中
将对象的引用保存到某个正确构造对象的 final 类型域中。
将对象的引用保存到一个由锁保护的域中。
线程安全库中的容器类提供了一下的安全发布保证:
通过将一个键或者值放入 Hashtable、synchronizedMap 或者 ConcurrentMap 中,可以安全地将它发布给任何从这些同期中访问它的线程(无论是直接访问还是通过迭代器访问)
通过将某个元素放入 Vector、CopyiOnWriteArrayList、CopyOnWriteArraySet、synchronizedList 或 synchronizedSet 中,可以将该元素安全地发布到任何从这些容器中访问该元素的线程。
通过将某个元素放入 BlockingQueue 或者 ConcurrentLinkedQueue 中,可以将该元素安全地发布到任何从这些队列中访问该元素的线程。
通常,要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器:
public static Holder holder = new Holder(42);
单例模式了解下。。。。
安全地共享对象
当发布一个对象时,必须明确地说明对象的访问方式。
在并发程序中使用和共享对象时,可以使用一些实用的策略包括:
线程封闭:线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
只读共享:在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
线程安全共享:线程安全的对象在其内部实现同步,因此对个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。
保护对象:被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。
2.3、逸出
this益出案例
并发编程实践中,this引用逃逸("this"escape)是指对象还没有构造完成,它的this引用就被发布出去了。这是危及到线程安全的,因为其他线程有可能通过这个逸出的引用访问到“初始化了一半”的对象(partially-constructed object)。这样就会出现某些线程中看到该对象的状态是没初始化完的状态,而在另外一些线程看到的却是已经初始化完的状态,这种不一致性是不确定的,程序也会因此而产生一些无法预知的并发错误。在说明并发编程中如何避免this引用逸出之前,我们先看看一个对象是如何产生this引用逸出的。
this引用逸出是如何产生的
正如代码清单1所示,ThisEscape在构造函数中引入了一个内部类EventListener,而内部类会自动的持有其外部类(这里是ThisEscape)的this引用。source.registerListener会将内部类发布出去,从而ThisEscape.this引用也随着内部类被发布了出去。但此时ThisEscape对象还没有构造完成 —— id已被赋值为1,但name还没被赋值,仍然为null。
this引用逸出示例
public class ThisEscape {
public final int id;
public final String name;
public ThisEscape(List<ThisEscape> source) {
id = 1;
source.add{this);
name = "zhangsan";
}
}
三、解决对象共享问题
3.1、线程封闭
当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不同享数据。如果仅在单线程内访问数据,就不需要同步。这种技术称为线程封闭(Thread Confinement),它是实现线程安全性的最简单方式之一。
线程封闭技术的常见应用时 JDBC 的 Connection 对象。线程从连接池中获得一个 Connection 对象,并且用该对象来处理请求,使用完后再将对象返还给连接池。由于大多数请求都是由单个线程采用同步的方式来处理,并且在 Connection 对象返回之前,连接池不会再将它分配给其他线程,因此,这种连接管理模式在处理请求时隐含地将 Connection 对象封闭在线程中。
3.2、栈封闭
栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。局部变量的固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。栈封闭(也被称为线程内部使用或者线程局部使用,不要与核心类库中的 ThreadLocal 混淆)。
对于基本类型的局部变量,如下程序清单中 loadTheArk 方法的 numPairs,无论如何都不会破坏栈封闭性,由于任何方法都无法获得基本类型的引用,因此Java 语言的这种语义就确保了基本来兴的局部变量始终封闭在线程内。
3.3、ThreadLocal 类
维持线程封闭性的一种更规范方法就是使用 ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal 提供了 get 和 set 等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此 get 总是返回由当前执行线程在调用 set 时设置的最新值。
3.4、不变性
满足同步需求的另一种方法时使用不可变对象。到目前为止,我们介绍了许多与原子性和可见性相关的问题,例如得到失效数据,丢失更新操作或者观察到某个对象处于不一致的状态等等,都与多线程试图同时访问同一个可变的状态相关。如果对象的状态不会改变,那么这些问题与复杂性也就自然消失了。
不可变对象一定是线程安全的。
虽然在Java 语言规范和 Java 内存模型中都没有给出不可变性的正式定义,但不可变性并不等于将对象中所有的域都声明为 final 类型,即使对象中所有的域都是 final 类型的,这个对象也仍然是可变的,因为在 final 类型的域中可以保存对可变对象的引用。
当满足以下条件时,对象才是不可变的:
对象创建以后其状态不可能修改。
对象的所有域都是 final 类型。
对象时正确创建的(在对象的创建期间, this 引用没有逸出)。
看个例子:在可变对象基础上构建的不可变类
public class ThreeStooges { private final Set<String> stooges = new HashSet<>(); public ThreeStooges() { stooges.add("one"); stooges.add("two"); stooges.add("three"); } public boolean isStooge(String name) { return stooges.contains(name); } }
3.5、Final 域
在 Java 内存模型中,final 域还有着特殊的语义。final 域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无需同步。
正如“除非需要更高的可见性,否则应将所有的域都声明为私有域”是一个良好的编程习惯,“除非需要某个域是可变的,否则应将其声明为 final 域”也是一个良好的编程习惯。