Java内存一致性

时间:2023-03-09 03:44:57
Java内存一致性

问题

前段时间在做服务注册发现的时候,有一处这样的逻辑,先发现下游服务,然后再是服务启动,服务启动完毕才能注册服务,因为注册一个在启动过程中的服务显然是不正确的设计。

然而很不巧,我们目前使用的TThreadpoolServer,通过serve()方法来启动服务,然后就阻塞在这个serve()方法里一直接while循环不会退出了。

要想判断此服务是否已经成功启动,只能通过其他线程去调用其isServing()方法来判断其是否已经启动完毕可以提供服务了,代码如下

    new Thread(new Runnable() {
@Override
public void run() {
try {
server.serve();
} catch (Exception e) {
LOGGER.error("server start error!,", e);
serveFail = true;
}
}
}).start();//启动服务
while (!server.isServing() && !serveFail) {//等待服务启动完毕
}
ServerRegistryUtil.getInstance().register()//注册服务

偶然出现的现象是,这个while循环永远不会退出。

但是服务明明已经启动起来了。

这显然是一个内存可见性的问题,为什么主线程去判断这个isServing的时候总是拿不到最新的结果呢?

机智的doug lea大神早就看穿了一切,在他的 supplement to the book Concurrent Programming in Java: Design Principles and PatternsVisibility中写到

In particular, it is always wrong to write loops waiting for values written by other threads unless the fields are volatile or accessed via synchronization (see §3.2.6).

既然有前人带路了,那就简单翻译一下大神的文章。

当然,熟悉jvm内存模型机制对于这类逻辑的理解还是很有必要的,简单的说就是jvm的内存模型分为主内存和工作内存,主内存类似于我们常说的内存,能被所有CPU访问,而工作内存是介于CPU和内存中的一层缓存,每个CPU的工作内存是互相独立的。

Visibility

只有在以下情况时,一个线程对某个字段的修改才能确保被其他线程‘看见’。

  • 写入的线程释放了一个同步锁(synchronization lock),并且读的线程随后获得了这个同步锁。

    本质上,释放一个锁意味着强制将所有的修改从线程的工作内存刷新到主内存。获得一个锁意味着强制让线程从主内存刷新其可以访问的值。同步锁不仅提供了对一个同步方法或者代码块的同步访问,还对这些线程执行时所需要使用到的字段的内存效果进行了此类定义。

    注意到synchronized有两种意义:首先,他提供了一种高级别的协议的锁。同时还处理了内存系统保持对于使用同一个锁的不同线程对于字段值的可见性保证(有时通过内屏障来实现)。这也从某种程度反映出相对于顺序编程,并发编程更类似于分布式编程。同步的另一个特性可以看做是一种机制,一个线程在运行同步的方法时,他将向其他线程发送或接收其他线程(在同步方法中)对字段的修改,从这点来看,使用锁和发送消息仅仅是语法不同而已。

  • 一个字段已经被申明成volatile。所有对它的值的修改,在此线程执行任何的后续内存操作前,都会被强制刷新,使得他的最新值对其他线程可见。每次读取volatile字段的值都必须强制从主内存刷新。

  • 一个线程第一次访问一个对象的某个字段的时候,他将会看到此字段的初始化值,或者是其他线程修改后的值。

    注意,将一个尚未完成构造的对象的引用暴露出来是一个错误的做法(见2.1.2),同样在一个构造方法里面启动新的线程也是危险的。

    特别是对于一个可以被继承的类。Thead.start有这样的内存效果:thread调用start的时候将释放一个锁,紧接着已经start的thread将获得这个锁,如果一个实现了Runnable的超类在子类的构造方法执行前调用了new Thread(this),这样对象就有可能在run方法执行时还尚未完成构造。

    同样的,如果你创建并且启动了一个新的线程T,在这之后你创建了一个对象X,并且你还在线程T里使用到了他,你不确定X的所有字段都能被线程T所看见,除非你在所有使用到了线程T的地方都加上同步锁,如果可以的话,你应该在T开始之前就创建X。(感觉这种方法也写不出来啊,编译器已经强制检查thread里用到了的x应该声明称final的)

  • 线程终止时,所有修改的变量都会被刷新到主存中。例如一个线程使用Thread.join来终止另一个线程,那么他肯定能看到另一个线程对变量值得修改。

注意,在单线程的方法间传递引用时,永远不会遇到内存可见性的问题。

线程模型保证了线程间的操作最终都会可见,一个线程对一个字段的修改最终都会被另一个线程看见。但是这个最终会花费多久就不好说了,没有使用同步的线程对于内存的可见性是很无助的。特别的,当一个字段不是volatile且也没有通过锁去同步时,一个线程在循环中单纯地去访问这个值,等待另一个线程对其进行修改,是永远也不可见的(参见3.2.6)。

线程模型同样也允许了在没有使用同步的情况下,可见性不一致的情况。例如,那个这个对象某一个字段的最新值,但是另一个字段的值却是旧的。同样,也可能读取到这个引用的值是最新的,即指向了一个新的对象,但是这个对象里面字段的值却是旧的。

不管怎样,线程模型只是允许了这种可见性不一致的发生,并不是说一定会发生。并不是说多个线程没有使用同步的话,就一定会出现内存可见性的问题,只是说有可能发生。从现在大多数的JVM的实现和表现来看,即使在使用了多处理器的JVM中,也很少出现这些问题。对于共享同一个CPU的多个线程来说,在缺少编译器优化,以及存在强缓存一致性的硬件,都会使得线程在更新字段值后立马传递给其他线程。所以想通过测试来发现由于线程可见性导致的错误是不现实的,因为这种情况极少发现,或者说只在你没有使用过的平台上出现,或者只会在将来的某个平台上出现。这些观点用于描述多线程编程的可能出现的问题来说更合适,在没有使用同步时,引起并发编程错误的原因有很多,其中就包括了内存一致性的问题。

思考

读完大师的文章,所以对于这里为什么出现问题就已经了解了,使用synchronized是一个办法。

使用volatile却并不能解决这里的问题,因为我们这里是需要查看isServing字段的可见性,而isServing字段是TServer的一个字段,我们无法将其修改成volatile的。

内存模型对于一致性的保证中,对于普通的一致性,会确保最终可见,最终可见这个事情其实是CPU帮你从主内存中重载了数据到工作内存。但是如果这个线程一直在while循环里进行单纯的CPU操作,那么就意味着线程一直占用着CPU,CPU完全没有时间来从主内存同步工作内存,所以会导致最终可见性永远不会发生。

调用Thread.sleep(),或者是其他一些可以让CPU闲下来的操作都可以使得最终可见性发生,比如涉及到内存分配或者IO的操作。而且从实践的经验来看,JVM的优化确实不错,几乎是立即可见了,所以在本例中只是单纯地使用了TimeUnit.MILLISECONDS.sleep(1)

使用synchronized这种强一致的方法进行,也存在着风险,如果等待通知的线程先获得了锁,那么服务就不会启动了,使用何种方法,应该由具体的业务场景来决定。

另一方面,昨天正好看了effective java这本书,case 70说的也是这个问题,看来还是书读的太少啊。

其他

接下来讲一下,Thread.yield(),Thread.sleep(),Object.wait()的异同点。

首先,他们都会让出CPU,此时CPU就可以去做一些其他的事情,比如上文中我们提到的,保证内存最终可见性的发生,也就是从主内存重新load数据到工作内存。

yield(),是指当前线程将让出cpu,让调度器去重新调度,线程本身还是处于就绪状态的,有可能调度器又调度了当前线程,这些都是不确定的,基于调度器的逻辑。

sleep(),则是让当前线程sleep一定的时间,此时线程处于sleep状态,时间到了之后,重新进入就绪状态。sleep并不会释放锁资源。

Object.wait(),则是让线程处于一个waiting状态,也可以说是一个阻塞状态。当被同一个Object在执行notify()或者notifyAll()的时候,线程会重新进入就绪状态。wait()主要的作用是用于线程间通信的。只能在被Synchronized的代码块中调用,否则IllegalMonitorStateException。

线程本身执行的代码中,有需要等待IO输入,或者需要等待内存分配或者访问的,或者是锁的存在,都会使得线程进入阻塞状态。直到等待的操作完成时,或者等待的资源被释放时,才会重新进入运行状态。

状态流转请看下图(来源)

Java内存一致性

接下来讲一个小错误,new Thread(Runnable() ->).start(),这是启动一个新的线程去执行里面的run方法,当run方法结束时,新线程退出。而new Thread(Runnable() ->).run(),则是在当前线程执行一次run方法。

参考文档

Synchronization and the Java Memory Model by doug lea

What is difference between wait and sleep in Java?