欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

在并发Java应用程序中检测可见性错误

程序员文章站 2022-03-30 23:05:14
了解什么是可见性错误,为什么会发生,以及如何在并发Java应用程序中查找难以捉摸的可见性错误。这些问题你可能也遇到过,当在优锐课学习了一段时间后,我对这些问题有了一定见解,写下这篇文章和大家分享。 检测可见性错误的机会各不相同。在最佳情况下,可以在所有情况的90%中检测到以下可见性错误。在最坏的情况 ......

了解什么是可见性错误,为什么会发生,以及如何在并发java应用程序中查找难以捉摸的可见性错误。这些问题你可能也遇到过,当在优锐课学习了一段时间后,我对这些问题有了一定见解,写下这篇文章和大家分享。

检测可见性错误的机会各不相同。在最佳情况下,可以在所有情况的90%中检测到以下可见性错误。在最坏的情况下,检测错误的机会低于百万分之一。

但是首先,什么是可见性错误?

 

什么是可见性错误?

当线程读取陈旧值时,会发生可见性错误。在以下示例中,一个线程向另一个线程发出信号以停止其while循环的处理:

 1 public class termination {
 2    private int v;
 3    public void runtest() throws interruptedexception   {
 4        thread workerthread = new thread( () -> { 
 5            while(v == 0) {
 6                // spin
 7            }
 8        });
 9        workerthread.start();
10        v = 1;
11        workerthread.join();  // test might hang up here 
12    }
13  public static void main(string[] args)  throws interruptedexception {
14        for(int i = 0 ; i < 1000 ; i++) {
15            new termination().runtest();
16        }
17    }    
18 }

 

错误是工作线程可能永远不会看到变量v的更新,因此将永远运行。

读取过时的值的原因之一是cpu内核的缓存。现代cpu的每个内核都有自己的缓存。因此,如果读取和写入线程在不同的内核上运行,则读取线程将看到缓存的值,而不是写入线程写入的值。 下面显示了超级用户答案给出的intel pentium 4 cpu内部的内核和缓存:

在并发Java应用程序中检测可见性错误

 

 

intel pentium 4 cpu的每个核心都有自己的1级和2级缓存。所有内核共享一个大的3级缓存。这些缓存的原因是性能。下列数字显示了访问内存所需的时间,摘自《计算机体系结构,一种定量方法》,jl hennessy,da patterson,第5版,第72页:

  • cpu寄存器〜300皮秒
  • 1级缓存〜1纳秒
  • 主内存〜50-100纳秒

读取和写入普通字段不会使高速缓存无效,因此,如果不同内核上的两个线程读取和写入同一变量,则它们将看到陈旧的值。让我们看看是否可以重现此错误。

 

如何重现可见性错误

如果你运行了上面的示例,则很有可能该测试无法挂断。该测试只需要很少的cpu周期,因此两个线程通常都在同一内核上运行,并且当两个线程在同一内核上运行时,它们将读取和写入同一缓存。幸运的是,openjdk提供了jcstress工具,可以帮助进行这种类型的测试。jcstress使用多种技巧,以便测试的线程在不同的内核上运行。这里,上面的示例被重写为jcstress测试:

 1 @jcstresstest(mode.termination)
 2 @outcome(id = "terminated", expect = expect.acceptable, desc = "gracefully finished.")
 3 @outcome(id = "stale", expect = expect.acceptable_interesting, desc = "test hung up.")
 4 @state
 5 public class apisample_03_termination {
 6     int v;
 7     @actor
 8     public void actor1() {
 9         while (v == 0) {
10             // spin
11         }
12     }
13     @signal
14     public void signal() {
15         v = 1;
16     }
17 }

 

此测试来自jcstress示例。通过使用注解@jcstresstest对该类进行注解,我们告诉jcstress此类是jcstress测试。jcstress在单独的线程中运行以@actor@signal注释的方法。jcstress首先启动actor线程,然后运行信号线程。如果测试在合理的时间内退出,则jcstress记录"terminated"结果;否则,结果为"stale."

jcstress使用不同的jvm参数多次运行测试用例。这是在我的开发机器(使用测试模式压力的intel i5 4核cpu)上进行此测试的结果。

 

对于jvm参数-xx:-tieredcompilation,在所有情况下90%都挂起线程,但是对于jvm flags -xx:tieredstopatlevel=1 and -xint,该线程在所有运行中终止。

在确认我们的示例确实包含一个错误之后,我们如何解决它?

 

如何避免可见性错误

java有专门的指令,可确保线程始终看到最新的写入值。易失性字段修饰符就是这样的一条指令。读取易失性字段时,可以确保线程看到最后写入的值。该保证不仅适用于字段的值,而且适用于在写入volatile变量之前由写入线程写入的所有值。从以上示例中,将字段修饰符volatile添加到字段v中,可以确保while循环始终终止,即使在使用jcstress的测试中运行也是如此。

1 public class termination {
2    volatile int v;
3    // methods omitted
4 }

 

volatile字段修饰符不是给出此类可见性保证的唯一指令。例如,包java.util.concurrent中的synced语句和类提供相同的保证。brian goetz等人撰写的《java concurrency in practice》一书很好地了解了避免可见性错误的技术。

在了解了可见性错误发生的原因以及如何重现和避免它们之后,让我们看一下如何查找它们。

 

如何查找可见性错误

java语言规范第17章。线程和锁正式定义了java指令的可见性保证。该规范定义了所谓的“先发生”关系来定义可见性保证:

“两个动作可以通过在发生之前的关系进行排序。如果一个动作在另一个发生之前,则第一个对第二个可见并且在第二个之前进行排序。”

读取和写入易失性字段会创建这样的事前关联:

“在每次对该字段进行后续读取之前,都会对易失字段(第8.3.1.4节)进行写操作。”

使用此规范,我们可以检查程序是否包含可见性错误,在规范中称为“数据争用”。

“当程序包含两个冲突访问(第17.4.1节)时,它们之间没有按事前发生的关系排序,则该程序被称为包含数据竞争。对同一变量的两次访问(读或写)被称为:如果至少有一个访问是写操作,则冲突。”

在我们的示例中,我们看到对共享变量v的读取和写入之间没有“先发生后”关系,因此该示例包含根据规范的数据竞争。

当然,这种推理可以自动化。以下两个工具使用此规则自动检测可见性错误:

  • threadsanitizer使用c ++内存模型的规则来查找c ++应用程序中的可见性错误。c ++内存模型由正式规则组成,用于指定c ++指令的可见性保证,类似于java语言规范对java指令所做的保证。有一个java增强建议的草案,即jep草案:java thread sanitizer,将threadsanitizer包含在openjdk jvm中。 应该通过命令行标志启用threadsanitizer的使用。
  • , 是我编写的用于测试并发java的工具,它使用java语言规范自动检查java测试运行是否包含可见性错误。