03_对象的共享
对象的共享
一、共享对象可能存在的问题?
- 多个线程同时访问或修改共享对象时,可能会出现冲突,即原子性问题
- 一个线程修改对象状态后,其他线程未必能够看到发生的变化,即内存可见性
二、共享对象的可见性
2.1 可见性问题有哪些?
内存可见性可能带来的问题:
- 无法确保一个线程在修改状态时,其他线程能够适时看到状态变化
- 比如,多核运CPU、多级缓存、重排序等,都可能会影响线程对对象状态的读取
举个栗子:
1 | public class Novisibility { |
理论上说,等到 ready 为 true 后,子线程就会打印出 number 的值 42。
但是由于内存可见性,可能会出现几种情况:
主线程在修改 ready 为 true 后,可能子线程看到的 ready 仍然是 false,结果就是程序一直循环等待下去;
还有一种可能,就是子线程会打印出 0,而不是 42。因为存在重排序的问题,代码
ready = true;
有可能会在number = 42;
之前执行,导致数据与预期不符。
内存可见性,会带来一些数据读写的有效性问题,比如失效数据、错误数据等。
2.1.1 失效数据
- 一个线程修改了对象状态,其他线程可能获取到状态的最新值,也可能获取到旧的失效值
- 失效数据可能会导致意料之外的异常、数据结构被破坏、不精确的计算等等问题
2.1.2 错误的64位值
- Java内存模型要求,变量的读取和写入都是原子操作
- 但是对于非 volatile 类型的 long 和 double 等 64 位的变量,JVM 是允许将 64 位的读写操作分成两个 32 位的读写操作的,这样就可能会出现错误的 64 位值
- 比如,先读取了变量的前 32 位数据,结果这个时候被别的线程修改了变量值,然后读取后 32 位数据,但是此时可能读到的是修改后的值的后 32 位数据,这时就会出现一个异常的不存在的 64 位值
2.2 如何解决可见性问题
- 为了确保多线程之间的读写操作可见性,必须使用同步机制
2.2.1 内置锁(监视器)
- 内置锁(监视器)是一种同步机制,它可以保证多个线程之间的读写操作可见性
- 为了保证所有线程都能够看到共享变量的最新值,所有读写线程都必须在同一个锁上同步
2.2. volatile 变量
- volatile 变量提供了一种稍弱的同步机制,也可以保证线程之间的可见性
- 读取 volatile 变量时,总是会返回最新写入的值
- 写入 volatile 变量时,总是会立即写入新值,但是不会等待其他线程的写入,即不会阻塞其他线程的写操作
- volatile 是一种比内置锁更轻量级的同步机制,因为它不会阻塞其他线程
三、什么是线程封闭(避免共享)?
- 解决内存可见性问题的最简单方法,就是不共享变量,那样就不会出现内存可见性问题
- 如果不共享数据,仅在单线程中访问数据,就不需要同步,这种技术称为线程封闭
3.1 Ad-hoc 线程封闭
- 线程封闭实现,由程序自己实现和维护
- 比如使用 volatile 变量,可以看到其他线程修改的最新值,但是不能避免线程安全性
- 这种由程序自己维护的线程封闭实现,代价比较大,而且比较脆弱
3.2 栈封闭
- 只使用局部变量去访问对象,称为栈封闭
- 每个线程都有自己的栈,局部变量都在栈上,所以局部变量是只属于当前线程的
- Java语言确保基本类型的局部变量始终是封闭在线程内的
- 对于引用类型的基本变量,只有局部生成的对象才是线程封闭的
- 这种方式,只能确保在栈中的局部对象是安全的,如果局部变量引用的是全局对象,全局对象还是会存在线程安全问题
3.3 线程本地变量 ThreadLocal
- ThreadLocal 可以确保每个线程都拥有属于自己的变量副本
- ThreadLocal 变量的修改,只对当前线程生效,不会影响其他线程
- 这种方式可以确保对象是安全的,但是不应该滥用,毕竟 ThreadLocal 变量保存的对象虽然是安全的,但是它自己本身却相当于是一种全局变量,那就会存在线程安全问题
四、如何安全共享?
想要在多个线程之间共享对象,必须确保安全地进行共享。
否则,可能会出现各种并发问题,比如线程之间的可见性问题。
1 | public class Share { |
类似这种方式共享的对象,是非常不安全的,很容易就出现线程安全问题。比如:
- instance 共享对象可能被多个线程创建多次,最终只保留了最后的一个对象
- 其他线程可能看不到最新的 instance 共享对象,看到的可能是 null,也可能是某个实例
- 其他线程看到的共享对象 instance 的状态 number 可能是失效的,即 number 不是 11,而可能是 0
反正如果不进行安全发布,共享对象就可能存在各种并发问题。
4.1 什么是发布和逸出?
发布(Publish):使得对象能够在当前作用域之外的代码使用。
比如:
- 将对象引用保存到其他代码可以访问的地方,比如保存到静态变量上
- 从方法调用中返回对象引用,使得其他地方可以使用
- 将对象引用作为参数传递给其他方法
简单来说,就是将一个局部生成的对象暴露给外面,使得外面的代码可以使用它,这就是发布,实际就是共享对象。
逸出(Escape):某个不应该发布的对象被发布出去了。
比如:
1 | public class Escape { |
发布地数组 numbers 实际上已经属于逸出了,因为它的原始引用被发布到了外部,调用者就可以直接修改数组的数据。
实际上,应该复制一份数组,再进行发布,避免直接修改原数组,从而避免逸出。
虽然调用者不一定会对逸出数据进行操作,但是误用该引用的风险始终是存在的。
4.2 什么是安全地构造对象?
安全地构造对象是指:
- 不要发布一个尚未构造完成的对象
- 不要在构造过程中使 this 引用逸出
常见的不安全构造对象方式有:
- 在构造函数中启动一个线程,对象未构造完成之前,线程就可以看见 this 引用
- 在构造函数中调用可重写的方法,就有可能在子类中被逸出
举个例子:
1 | public class UnsafePublish { |
因为在对象还未构造完成之前,线程有可能就执行了,而此时对象的状态 number 可能是不对的。
4.3 共享对象类型有哪些?
4.3.1 不可变对象
不可变对象(Immutable Object):对象在其被创建之后就不能被修改。
需满足以下条件:
- 对象创建后其状态就不能修改
- 对象的所有域都是 final 类型(技术上不一定,只要保障不被修改即可)
- 对象是正确安全构造好的(没有 this 逸出等)
不可变对象的特性:
- 不可变对象一定是线程安全的
因为线程的不安全性就是来源于状态的变化,不可变对象的状态无法改变,就不存在线程安全问题。
4.3.2 事实不可变对象
事实不可变对象(Effectively Immutable Object):技术上对象是可变的,但是在程序中发布后就不会再改变。
需满足条件:
- 对象一旦创建完成后,就不会再有人去修改它,即使它是可以修改的
事实不可变对象的特性:
- 安全发布的事实不可变对象是线程安全的
注意,事实不可变对象需要“安全发布”,才可以保证线程安全。
4.3.3 可变对象
可变对象:就是普通的发布对象,可以被任意线程修改。
4.4 如何安全发布?
4.4.1 安全发布的要求
- 要安全地发布对象,必须保证对象的引用和对象的状态是同时对其他线程可见的
4.4.2 安全发布的常用模式
- 在静态初始化函数中初始化一个对象引用
- 将对象的引用保存到 volatile 类型的域或者原子类 AtomicReference 中
- 将对象的引用保存到某个正确构造对象的 final 类型域中
- 将对象的引用保存到一个由锁保护的域中
简单举例(实际不一定是这样用的):
1 | public class Share { |
4.4.3 安全共享对象
- 不可变对象,可以通过任意机制发布
- 事实不可变对象,必须通过安全方式发布
- 可变对象,必须通过安全方式发布,并且使用时必须是线程安全的(如由原子类或者某个锁保护起来)
总结
共享对象问题:
- 多个线程同时访问或修改共享对象时,可能会出现冲突,即读写冲突
- 一个线程修改对象状态后,其他线程未必能够看到发生的变化,即内存可见性
可见性问题:
- 无法确保一个线程在修改状态时,其他线程能够适时看到状态变化
- 失效数据:一个线程修改了对象状态,其他线程可能获取到状态的最新值,也可能获取到旧的失效值
- 错误数据:对于非 volatile 类型的 long 和 double 等 64 位的变量,JVM 是允许将 64 位的读写操作分成两个 32 位的读写操作的,这样就可能会出现错误的 64 位值
可见性解决方案:
- 为了确保多线程之间的读写操作可见性,必须使用同步机制
- 内置锁(监视器)
- 内置锁(监视器)是一种同步机制,它可以保证多个线程之间的读写操作可见性
- 为了保证所有线程都能够看到共享变量的最新值,所有读写线程都必须在同一个锁上同步
- volatile 变量
- volatile 变量提供了一种稍弱的同步机制,也可以保证线程之间的可见性
- 读取 volatile 变量时,总是会返回最新写入的值
- 写入 volatile 变量时,总是会立即写入新值,但是不会等待其他线程的写入,即不会阻塞其他线程的写操作
- volatile 是一种比内置锁更轻量级的同步机制,因为它不会阻塞其他线程
线程封闭(不共享):
- Ad-hoc 线程封闭
- 线程封闭实现,由程序自己实现和维护
- 这种由程序自己维护的线程封闭实现,代价比较大,而且比较脆弱
- 栈封闭
- 每个线程都有自己的栈,局部变量都在栈上,所以局部变量是只属于当前线程的
- Java语言确保基本类型的局部变量始终是封闭在线程内的
- 对于引用类型的基本变量,只有局部生成的对象才是线程封闭的
- 线程本地变量 ThreadLocal
- ThreadLocal 可以确保每个线程都拥有属于自己的变量副本
- ThreadLocal 变量的修改,只对当前线程生效,不会影响其他线程
发布和逸出:
- 发布(Publish):使得对象能够在当前作用域之外的代码使用
- 逸出(Escape):某个不应该发布的对象被发布出去了
安全构造对象:
- 不要发布一个尚未构造完成的对象
- 不要在构造过程中使 this 引用逸出
共享对象类型:
- 不可变对象(Immutable Object):对象在其被创建之后就不能被修改
- 事实不可变对象(Effectively Immutable Object):技术上对象是可变的,但是在程序中发布后就不会再改变
- 可变对象:就是普通的发布对象,可以被任意线程修改
安全发布模式:
- 在静态初始化函数中初始化一个对象引用
- 将对象的引用保存到 volatile 类型的域或者原子类 AtomicReference 中
- 将对象的引用保存到某个正确构造对象的 final 类型域中
- 将对象的引用保存到一个由锁保护的域中
安全共享对象:
- 不可变对象,可以通过任意机制发布
- 事实不可变对象,必须通过安全方式发布
- 可变对象,必须通过安全方式发布,并且必须是线程安全的或者由某个锁保护起来的