03_对象的共享

对象的共享

一、共享对象可能存在的问题?

  • 多个线程同时访问或修改共享对象时,可能会出现冲突,即原子性问题
  • 一个线程修改对象状态后,其他线程未必能够看到发生的变化,即内存可见性

二、共享对象的可见性

2.1 可见性问题有哪些?

内存可见性可能带来的问题:

  • 无法确保一个线程在修改状态时,其他线程能够适时看到状态变化
  • 比如,多核运CPU、多级缓存、重排序等,都可能会影响线程对对象状态的读取

举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Novisibility {
private static boolean ready;
private static int number;

public static void main(String[] args) {
new Thread(() -> {
while (!ready) {
Thread.yield();
}
System.out.println(number);
}).start();

number = 42;
ready = true;
}
}

理论上说,等到 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Share {

public static Share instance;

public int number;

public Share(int number) {
this.number = number;
}

public void init() {
if (instance == null) {
instance = new Share(11)
}
}

}

类似这种方式共享的对象,是非常不安全的,很容易就出现线程安全问题。比如:

  • instance 共享对象可能被多个线程创建多次,最终只保留了最后的一个对象
  • 其他线程可能看不到最新的 instance 共享对象,看到的可能是 null,也可能是某个实例
  • 其他线程看到的共享对象 instance 的状态 number 可能是失效的,即 number 不是 11,而可能是 0

反正如果不进行安全发布,共享对象就可能存在各种并发问题。

4.1 什么是发布和逸出?

发布(Publish):使得对象能够在当前作用域之外的代码使用。

比如:

  • 将对象引用保存到其他代码可以访问的地方,比如保存到静态变量上
  • 从方法调用中返回对象引用,使得其他地方可以使用
  • 将对象引用作为参数传递给其他方法

简单来说,就是将一个局部生成的对象暴露给外面,使得外面的代码可以使用它,这就是发布,实际就是共享对象。

逸出(Escape):某个不应该发布的对象被发布出去了。

比如:

1
2
3
4
5
6
7
8
9
public class Escape {
private String[] numbers = new String[] {
"1", "2"
};

public String[] getNumbers() {
return numbers;
}
}

发布地数组 numbers 实际上已经属于逸出了,因为它的原始引用被发布到了外部,调用者就可以直接修改数组的数据。

实际上,应该复制一份数组,再进行发布,避免直接修改原数组,从而避免逸出。

虽然调用者不一定会对逸出数据进行操作,但是误用该引用的风险始终是存在的。

4.2 什么是安全地构造对象?

安全地构造对象是指:

  • 不要发布一个尚未构造完成的对象
  • 不要在构造过程中使 this 引用逸出

常见的不安全构造对象方式有:

  • 在构造函数中启动一个线程,对象未构造完成之前,线程就可以看见 this 引用
  • 在构造函数中调用可重写的方法,就有可能在子类中被逸出

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
public class UnsafePublish {

private int number;

public UnsafePublish(int n) {
new Thread(() -> {
System.out.println(number);
}).start();

this.number = n;
}
}

因为在对象还未构造完成之前,线程有可能就执行了,而此时对象的状态 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Share {
// 由锁保护的域
private static Map<String, Share> instances = new ConcurrentMap<>();
static {
instances.put("share", new Share(0))
}
// 静态初始化函数
private static Share instance1 = new Share(1);
// volatile 类型
private static volatile Share instance2;
// final 域
private final Share instance3;

private int number;
public Share(int number) {
this.number = number;
}

public Share(Share share) {
this.instance3 = 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 类型域中
  • 将对象的引用保存到一个由锁保护的域中

安全共享对象:

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

jiaduo

发布于

2022-05-15

更新于

2023-04-03

许可协议