咨询一个关于 synchronized 问题

2020-11-19 14:20:05 +08:00
 levizheng

单例模式有一种 双重检验锁的写法,其中 volatile 用来保证 Jit 编译器进行指令重排,以防其他线程获取了未初始化的实例。

我的问题是:synchronized 关键字不是可以保证线程的可见性、原子性和有序性吗?不是在执行完同步代码释放锁之前将工作内存的值同步刷新到主内存中吗?如果是这样那么其他线程在 2 次检测的时候不应该都是拿到的是非 null 的且已经初始化的实例吗?为什么还会有这种情况呢?

怀疑的方向: 难道说 synchronized 在释放锁之前就已经会把工作内存的一些值同步到主内存?那同步还有啥意义呢。

public class Singleton {
   private volatile static Singleton uniqueInstance;
   private Singleton() {}

  public static Singleton getUniqueInstance() {
   		//先判断对象是否已经实例过,没有实例化过才进入加锁代码
      	if (uniqueInstance == null) {
          	//类对象加锁
          	synchronized (Singleton.class) {
              	if (uniqueInstance == null) {
                  	uniqueInstance = new Singleton();
              	}
          	}
      	}
      return uniqueInstance;
  }

2091 次点击
所在节点    Java
20 条回复
aerzha
2020-11-19 14:28:49 +08:00
这儿是为了防止指令重排后,线程 A 将 instance 赋值,但还未执行实例对象初始化,线程 B 在外层直接认定为非 null,返回一个未初始化完成的 instance
fengpan567
2020-11-19 14:30:49 +08:00
初始化的时候是先给对象分配内存地址,但是对象还未实际初始化完成,所以可能导致其他线程误判
kkkkkrua
2020-11-19 14:32:50 +08:00
DLC 没那么复杂,其实你困扰的地方恰巧不在 synchronized 这里,
假如有 A,B 两个线程
if (uniqueInstance == null) {
//这里没有锁,AB 线程同时进来,这个是可能存在的
synchronized (Singleton.class) {
//有锁后,排队进入,假如 A 先进来,肯定是 null,那么 B 线程肯定进不去
if (uniqueInstance == null) {
//A 线程实例化
uniqueInstance = new Singleton();
}
}
}
//B 走到这,返回创建好的实例
return uniqueInstance;
levizheng
2020-11-19 14:42:58 +08:00
@aerzha
@fengpan567
@kkkkkrua
其实我想问的问题就是,为什么除了一开始持有锁的 A 线程外,其他线程会在外层判断是非 null 。
我一直觉得是当线程 A 释放了锁之前 才会把工作内存的值更新到主内存,觉得线程 A 在没释放前,外面的所有线程都应该获取的是 null 。。不知道我这个理解是不是错了。
letianqiu
2020-11-19 14:49:56 +08:00
多个线程可以同时进入这个方法,然后发现是 null,但是只有一个线程会拿到锁然后创建对象
letianqiu
2020-11-19 14:59:35 +08:00
我想我明白楼主的问题了。你的理解有偏差。因为这里判断是不是 null 的 read 不涉及 monitor,所以和 synchronized 的 block 里的 write 不构成 happens before 的关系。加了 volatile 之后,happens before 就成立了
Joker123456789
2020-11-19 15:04:54 +08:00
首先呢,同步不是为了防止获取 null 值,你都 if ( xxx=null )了,还怎么可能返回 null 呢? 同步是为了防止获取多个实例。

比如项目刚启动,这个单例还没被实例化,此时两个并发过来了。

比如 A 和 B 同时调用了 getXX 方法, 当 A 进入了 if 以后,在执行 xxx = new XXX(); 之前,B 进来了,此时 xxx 还是空的吧?

那么 B 也会进入 if 对不对?

此时 A 执行了 xxx = new XXX(); 并返回了,B 才开始执行 xxx = new XXX();

A 和 B 是不是获取到的对象不一样? 那这就不是单例了啊。

所以必须等 A 拿到了返回值,B 才能进来,所以才用同步锁。

-----------------------------------------------

然后就是你的问题了,在锁释放之前,其他线程是无法执行这一段代码的,这才是它的意义。 至于你说的释放前会不会同步到主内存,那肯定是不会的,但是如果你加了 volatile 就会同步。

最后,你这段代码有点过于复杂了,直接在 getUniqueInstance 方法上加个锁不就好了。其他的都可以删掉了。
或者你干脆 用饱汉模式,private static Singleton uniqueInstance = new Singleton(); 在 getUniqueInstance 方法里直接 return 就好了,都不需要锁。这种支持并发,效率高一些。
fengpan567
2020-11-19 15:08:48 +08:00
@levizheng volatile 关键字是实时更新到主内存的,不用等待释放锁
azygote
2020-11-19 15:15:35 +08:00
volatile 的作用主要是为了 synchronized 外面那个 if 的。如果不加 volatile 的话,会出现一个情况:有一个线程正在对 uniqueInstance 赋值,由于没有 volatile 关键字,uniqueInstance 可能返回一个初始化一半了的对象,然后这时恰好另外一个线程正好执行到 if (uniqueInstance == null),得到结果为 false 然后直接返回, 但实际上这个 uniqueInstance 读到的值并没有初始化完成,导致返回了初始化了一半的对象,最后造成程序 crash 。

引自维基百科: https://en.wikipedia.org/wiki/Double-checked_locking

Intuitively, this algorithm seems like an efficient solution to the problem. However, this technique has many subtle problems and should usually be avoided. For example, consider the following sequence of events:

Thread A notices that the value is not initialized, so it obtains the lock and begins to initialize the value.

Due to the semantics of some programming languages, the code generated by the compiler is allowed to update the shared variable to point to a partially constructed object before A has finished performing the initialization. For example, in Java if a call to a constructor has been inlined then the shared variable may immediately be updated once the storage has been allocated but before the inlined constructor initializes the object.[6]

Thread B notices that the shared variable has been initialized (or so it appears), and returns its value. Because thread B believes the value is already initialized, it does not acquire the lock. If B uses the object before all of the initialization done by A is seen by B (either because A has not finished initializing it or because some of the initialized values in the object have not yet percolated to the memory B uses (cache coherence)), the program will likely crash.
levizheng
2020-11-19 15:25:40 +08:00
@Joker123456789
懒汉模式+同步关键字效率还是低的,即使已经实例化其他线程还是在进行等待的
你说的饱汉模式应该是饿汉模式把。这个没有实现延迟实例化,如果很多的类的话,资源占用也是有的

我知道 volatile 关键字是为了防止指令重排,但是我觉得你说的内容不对哈哈哈
你说的 A 和 B 都进入了 if 里 俩都是空 都会进入 if 我是认同的,
所以这边要在同步代码块里面再次判断是否为 null,只有持有锁的线程才会进行实例,当释放锁的时候,其他的线程读到里面的值发现已经实例化就不会执行 if 里的内容。
Jooooooooo
2020-11-19 15:27:20 +08:00
levizheng
2020-11-19 15:29:35 +08:00
@azygote 我明白你的意思,我纠结的是如果没有 volatile 关键字,当我其中一个线程在实例化一半的时候,这时候其他线程在外面那个 if 判断读到的不应该是恒为 null 吗 应该一直是 true 把。。
Jooooooooo
2020-11-19 15:30:36 +08:00
这句 "难道说 synchronized 在释放锁之前就已经会把工作内存的一些值同步到主内存?那同步还有啥意义呢。"

你就理解错了整个 JMM 是在干嘛

不同硬件对于不同 CPU 的内存一致性保障不一样, 有些很强, 有些很弱, JMM 是为了拉齐不同硬件表现做的, 使用户不用关心底层的内存同步究竟使怎么做的

比如有些硬件对内存一致性保障很强, 你会发生生成的指令里面直接把 volatile 给优化掉了, 因为硬件已经保障了 volatile 的语义, 不需要额外的屏障去保证
Joker123456789
2020-11-19 15:32:08 +08:00
@levizheng

首先第一段,单例对象一旦实例化后,就会一直存在,你说饿汉模式占用资源,其实只是在一开始占用的比懒汉多,但是随着项目的运行,所有懒汉都将被实例化,最终占用的资源都是一样的

volatile 这个 我说的可能不对吧,谢谢你的指点,我再去深入学一下。

至于最后一段,我是在解释,同步锁不是为了防止获取 null 值,而是为了防止获取多个对象, 我并不是按照他的代码来说的。
Jooooooooo
2020-11-19 15:32:13 +08:00
更极端的例子, 你在单核机器跑任何多线程的程序会发现不用这些关键词都不会有内存不一致的问题
azygote
2020-11-19 15:40:04 +08:00
@levizheng 仔细读这段 "Due to the semantics of some programming languages, the code generated by the compiler is allowed to update the shared variable to point to a partially constructed object before A has finished performing the initialization. For example, in Java if a call to a constructor has been inlined then the shared variable may immediately be updated once the storage has been allocated but before the inlined constructor initializes the object.[6]"
如果不加 volatile, 有个线程对 uniqueInstance 初始化到一半,另外一个线程执行到 synchronized 外面的那个 if (uniqueInstance == null) 是可能返回 false 的,也就读到的不是 null,而是一个初始化了一半的对象。
levizheng
2020-11-19 15:42:37 +08:00
@Jooooooooo
@azygote
感谢,受教了,我再好好去领会一下
orangex
2020-11-19 16:01:22 +08:00
@levizheng
@Joker123456789
“饿汉式没有延迟实例化”一说实在是人云亦云,下面是一个“饿汉式”:

```java
public class SampleClass {
private static SampleClass sInst = new SampleClass();
public static SampleClass getInst() {
return sInst;
}
}
```

实例化发生在何时呢?也就是`sInst = new SampleClass();`在什么时候执行的呢?在类加载(加载.class 进内存 -> 链接 -> 初始化)的最后一步“初始化”中执行。

那初始化的发生是有条件的:

1. 创建类的实例
2. 访问类的静态成员
3. 初始化子类
4. 虚拟机标记为启动类
5. ……

因此,当你第一次触发上述条件时饿汉式才会实例化。而如果根据“单例 Class 内不应该拥有除了 getInst 方法外的其他能被访问的静态成员”的理念去组织代码的话,饿汉式在延迟实例化上与懒汉无二。
kitFrankenstein
2020-11-24 18:02:41 +08:00
“难道说 synchronized 在释放锁之前就已经会把工作内存的一些值同步到主内存”吗?同款疑问
kitFrankenstein
2020-11-25 15:41:38 +08:00
大概懂了 关键是 happens-before 吧
写主内存的时机其实 synchronized 并不能保证?

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/727116

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX