字节码指令级别从i++说到volatile,深入理解i++的线程安全问题

本文深入探讨了i++操作在多线程环境中的行为,通过字节码级分析揭示了线程安全问题的本质,并介绍了volatile关键字的作用及其对多线程安全性的影响。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

接下来的内容你可能会看得很恼火,但是请你仔细阅读,不懂的细节就百度,理解了这篇文章,对你的多线程安全将有很大幅度的提升,而不是只限于语法级别。

笔者序

很久都没有静下心写博客了,记得上次——也许没有上次。哈哈哈。

为自己而活,不是为你喜欢的人,也不是为你觉得重要的人。他们在你脆弱、迷茫、孤独,最需要帮助的时候,他们可能不会想到你,但是你自己却是能够及时的想到自己,知道自己最需要什么。

写博客地点:高新区文轩图书咖啡馆。

多线程安全问题分析

在很久以前,记得那个时候,我还在ACM上暑期集训,当时有看单例模式在多线程环境中的影响。在那个时候粗略了解到了 字节码指令 这个神奇的东西,还有 字节码指令 重排序。要不是那个时候看到了这些,对我学习这方面知识有引导作用,现在我也许会花上更多的时间去理解i++字节码级别的线程安全问题吧。

来,我们看个简单的i++代码,这也是多线程环境下出现安全问题的本质:

public class IplusTest {
    int i=12;
    public void run(){
        System.out.println(i++);
    }
}

现在通过命令查看当前程序的字节码指令:

啰嗦版:
javap -v -p class文件名(不要.class 后缀)

简洁版:
javap -c class文件名(不要.class 后缀)

我们学习的时候,需要啰嗦版:

在分析下面的指令的时候,你需要记住三个东西:本地变量表、常量池、操作数栈。

public class com.notifymyself.thread.createrproblems.IplusTest
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#16         // java/lang/Object."<init>":()V
   #2 = Fieldref           #5.#17         // com/notifymyself/thread/createrproblems/IplusTest.i:I
   #3 = Fieldref           #18.#19        // java/lang/System.out:Ljava/io/PrintStream;
   #4 = Methodref          #20.#21        // java/io/PrintStream.println:(I)V
   #5 = Class              #22            // com/notifymyself/thread/createrproblems/IplusTest
   #6 = Class              #23            // java/lang/Object
   #7 = Utf8               i
   #8 = Utf8               I
   #9 = Utf8               <init>
  #10 = Utf8               ()V
  #11 = Utf8               Code
  #12 = Utf8               LineNumberTable
  #13 = Utf8               run
  #14 = Utf8               SourceFile
  #15 = Utf8               IplusTest.java
  #16 = NameAndType        #9:#10         // "<init>":()V
  #17 = NameAndType        #7:#8          // i:I
  #18 = Class              #24            // java/lang/System
  #19 = NameAndType        #25:#26        // out:Ljava/io/PrintStream;
  #20 = Class              #27            // java/io/PrintStream
  #21 = NameAndType        #28:#29        // println:(I)V
  #22 = Utf8               com/notifymyself/thread/createrproblems/IplusTest
  #23 = Utf8               java/lang/Object
  #24 = Utf8               java/lang/System
  #25 = Utf8               out
  #26 = Utf8               Ljava/io/PrintStream;
  #27 = Utf8               java/io/PrintStream
  #28 = Utf8               println
  #29 = Utf8               (I)V
{
  int i;
    descriptor: I
    flags:

  public com.notifymyself.thread.createrproblems.IplusTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: bipush        12
         7: putfield      #2                  // Field i:I
        10: return
      LineNumberTable:
        line 6: 0
        line 7: 4
//这下面就是重要的部分,即run方法:
  public void run();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=5, locals=1, args_size=1
         0: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: aload_0
         4: dup
         5: getfield      #2                  // Field i:I
         8: dup_x1
         9: iconst_1
        10: iadd
        11: putfield      #2                  // Field i:I
        14: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
        17: return
      LineNumberTable:
        line 9: 0
        line 10: 17
}
SourceFile: "IplusTest.java"

我们只需要看重要的部分,一步一步来分析:

1.首先执行的是 getstatic 指令,意思是获取指定目标的常量,即getstatic加上后面的 #3 的意思就是,获取常量池中 索引为 #3 的常量,看上面#3 = Fieldref #18.#19 // java/lang/System.out:Ljava/io/PrintStream;就可以知道获取的是PrintStream这个实例对象。

2.aload_0 获取的是本地变量表中对象的引用,a代表引用类型,0表示本地表量表中变量的索引,通常0这个位置的引用,一般都是 this 对象。iload_0 获取的是 下标为 0 的整数类型。

3.dup 表示复制栈顶数据,并把复制后的值压入栈顶。

4.getfield #2 表示从栈顶引用指向的对象中(这里是this),获取常量池中下标为 #2 的数据。// Field i:I即 i 变量的值,强调,是值。知道是值这一点很关键,因为这是理解后面 volatile 的关键。

5.dup_x1 复制栈顶数据,把复制后的数据放入栈顶数据第二项后。

6.iconst_1 表示把数字 1 压入栈顶。

7.iadd 表示弹出栈顶的前面两个数据,作加法运算后再放入栈顶。

8.putfield #2 表示弹出栈顶两个数据,把值设置进入引用 的属性中,哪儿个属性呢,当然是下标 #2 对应的属性啦,该指令必须满足 第一个数据为 值,第二个数据为 引用,即数据载体。

9.invokevirtual #4 弹出栈顶的两个数据,第一个数据是输出数据,第二个是被调用方法的实例对象。#4 则表示调用哪儿个方法,可以到常量池中找。

到目前为止,栈已经是空的了。方法执行结束,记住,方法结束时,栈必须是空的。

以上过程,我们可以通过一张图来说明:

这里写图片描述

现在我们就知道了,执行一个 i++ 的输出会执行很多个步骤:

最终总结下就是:在第四个指令那里getfield #2,当获取成员变量值后,就已经定下了System.out.println()到底会输出什么,而之后第八步putfield #2也是如此,我们获取到了值,也定下了之后到底会设置进什么值。如果在其中加上多线程,出现线程互相抢占,就会出现值的重复,值为负数的情况了。现在是不是水到渠成,豁然开朗???当然,上面的内容你如果只读一遍就有这种感觉的话,那绝对是不可能的。告诉你了,细节的知识点不懂就百度,你还不信!!!懒了吧,想放弃了吧!!!还有就是你可以在评论区评论啊,我还可以量身定制的为你答疑!!

是时候引入volatile这个关键字了

在Java中设置变量值的操作,除了long和double类型的变量外都是原子操作,也就是说,对于变量值的简单读写操作没有必要进行同步。这在JVM 1.2之前,Java的内存模型实现总是从主存读取变量,是不需要进行特别的注意的。而随着JVM的成熟和优化,现在在多线程环境下volatile关键字的使用变得非常重要。在当前的Java内存模型下,线程可以把变量保存在本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。要解决这个问题,只需要像在本程序中的这样,把该变量声明为volatile(不稳定的)即可,这就指示JVM,这个变量是不稳定的,每次使用它都到主存中进行读取。一般说来,多任务环境下各任务间共享的标志都应该加volatile修饰。

在实际工作中很少有用到volatile这个关键字,今天在看一段开源代码时碰到它,查了一下它的用法 :

用在多线程,同步变量 线程为了提高效率,将某成员变量(如A)拷贝了一份(如B),线程中对A的访问其实访问的是B。只在某些动作时才进行A和B的同步。因此存在A和B不一致的情况。volatile就是用来避免这种情况的。

volatile告诉jvm, 它所修饰的变量不保留拷贝,直接访问主内存中的(也就是上面说的A)。

volatile的另外一个作用是禁止指令的重排序优化。

可以查看博客:Java的volatile关键字的作用

synchronized 关键字有两个作用,互斥(mutual exclusion) 和可见性(visibility)。互斥即一次只允许一个线程持有某个特定的锁,因此可使用该特性实现对共享数据的协调访问协议,这样,一次就只有一个线程能够使用该共享数据。可见性要更加复杂一些,它必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的 —— 如果没有同步机制提供的这种可见性保证,线程看到的共享变量可能是修改前的值或不一致的值,这将引发许多严重问题。

可以查看博客:synchronized 和 volatile

忘记说了,在多线程中,不管是 volatile 还是 synchronized 虽然都会主动同步线程工作内存数据到主内存中,但是别忘了,就算你同步了,其它线程也未必在运行的时候能够及时获取到最新值啊,所以每个线程一定有个刷新工作内存空间值的时机,那就是当线程运行的时候,即每次线程从就绪到运行这个阶段,都会主动刷新工作内存空间的值。当然这是我的猜测,你想想如果我某个线程已经同步数据到主内存了,你还是一直用的你从前拷贝的数据,那么我同步数据到主内存还有什么意义???有意义吗??并没有好吧。所以这种猜测一定是成立的!!!你看我后面的售票例子就会知道,如果我改变了 isEexecute 值,并且已经同步到主内存中去了,其它线程一直不从主内存中刷新最新值,那么该线程永远不会结束!!!其实,该段话最终的总结就是当一个线程修改了对象状态后,其他线程能够看到该变化。而线程的同步 synchronized 恰恰也能够实现这一点。

你为什么还会问i++有了volatile还是会出现重复的值

如果你真的看明白了我上面说的东西,你理解这个是很简单的,为什么呢???volatile修饰的变量,在多线程环境下,随时获取的都是最新的值。而它只是针对值的获取,而i++却是包括了值的获取、值的计算、值的赋值这三个关键步骤。当 i 加了 volatile 关键字,我们确实能够获取到最新值。但是如果这个线程获取到了最新值,突然其它的线程抢占到了时间片,也是获取到最新值,到头来还不是输出重复的值,所以i++这个东西,就算加不加volatile都没用,对值的重复和负值没有任何作用。

其实,在以前我还以为 i++ 加 volatile 会出现数值跳格的情况,比如100,100,100,97,97,95。。。现在想想,怎么可能会有这种情况对吧。因为确实是视频教程惹的祸,那些老师都乱讲。i 为成员变量时,说什么 i=i++ 有这几个执行步骤:中间变量缓存i的原始值,i作自增,i++计算完成后,把缓存的值赋值给 i 。呵呵,不在多线程环境时,这样分析确实没错能够得到正确答案,但并不是正解OK??,在多线程环境时,你就不得不从字节码级别分析了,不管多线程还是单线程,这才是正解!!!如果在多线程都按照上面视频老师的分析,那么 System.out.println(i++) 在输出多个重复值的情况下,就一定会出现跳格现象,你可能会说值被修改后不能立马在其它线程中看见,因为它是修改的线程中工作区域变量的拷贝,还没有同步到主内存中去,那么我加volatile总行吧,但是依旧出现重复值,却不出现跳格现象,就是因为,很多教程视频都只是知其表,而不知其然,为此 杨七七 还不想跟我解释。。。其实,这就是你不过脑的原因。。。

最后的最后

你可能读我的博客有些费力,不!是非常的费力!!!哈哈,这很正常,我可是花了一天的时间啊,其中最难的就是知道 java 指令的含义,在百度里面我最讨厌的就是那些把指令含义解释错了的博客。比如 dup_x1 这个指令。

比如:

博客地址:http://blog.youkuaiyun.com/u012877472/article/details/51541365

这里写图片描述

还有这个,都是很糟糕的:

博客地址:http://gityuan.com/2015/10/24/jvm-bytecode-grammar/

这里写图片描述

这两篇博客错误的解释耽搁了我两个多小时。

在突然要放弃那一刹那,我想到了你,杨七七,我还不能放弃啊。

直接百度推荐书籍。在书籍里面找到了正解。

再来个字节码案例分析:

public class Test{
    int j=12;
    public void run(){
        j=++j;
    }
}

这里写图片描述

参考资料:

书籍:《Java虚拟机规范-第二版-非扫描版》

参考博客:

大话+图说:Java汇编指令——只为让你懂

Java Volatile说明

Java字节码【维基百科】

JVM字节码执行模型及字节码指令集

Java的volatile关键字的作用

java线程内存模型,线程、工作内存、主内存

线程工作内存与主内存

synchronized 的另个一重要作用:内存可见性

这里写图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值