有关于 线程可见性和volatile关键字的问题

  • Post author:
  • Post category:其他




Volatile 关键字

前面写了一个多线程tcp读写的小功能;

当时的想法是,定义一个静态的static的变量来存储每一个线程所对应的链接发送的消息

结果发现主线程修改了值之后子线程读不到, 还是java 基础不够深厚啊; 为了快速完成功能,后面是采用了第三方存储库redis来存储这些数据;

现在功能完成了,想到这些问题,想起来关于线程可见性的这块儿,先写个博客记录一下, 后面有时间再去验证;


先来普及几个概念 jvm的构成

类加载器(ClassLoader)

运行时数据区(Runtime Data Area)

执行引擎(Execution Engine)

本地库接口(Native Interface)


jvm 工作顺序


我们写的代码计算机是无法识别的jvm就是我们的翻译;负责去和计算机沟通让计算器负责执行。

我们通过编译我们自己写的代码,编译为字节码文件.class 文件,jvm首先需要把字节码通过一定的方式 类加载器(ClassLoader) 把文件加载到内存中 运行时数据区(Runtime Data Area)而字节码文件是jvm的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器 执行引擎(Execution Engine) 将字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调用其他语言的接口 本地库接口(Native Interface) 来实现整个程序的功能,这就是这4个主要组成部分的职责与功能。


运行时数据区

jvm的运行时数据区,不同虚拟机实现可能略微有所不同,但都会遵从Java虚拟机规范,Java 8 虚拟机规范规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域:

程序计数器(Program Counter Register)

Java虚拟机栈(Java Virtual Machine Stacks)

本地方法栈(Native Method Stack)

Java堆(Java Heap)

方法区(Methed Area)

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解析器的工作是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

特性:内存私有

由于jvm的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,也就是任何时刻,一个处理器(或者说一个内核)都只会执行一条线程中的指令。因此为了线程切换后能恢复到正确的执行位置,每个线程都有独立的程序计数器。

异常规定:无

如果线程正在执行Java中的方法,程序计数器记录的就是正在执行虚拟机字节码指令的地址,如果是Native方法,这个计数器就为空(undefined),因此该内存区域是唯一一个在Java虚拟机规范中没有规定OutOfMemoryError的区域。

Java虚拟机栈

Java虚拟机栈(Java Virtual Machine Stacks)描述的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个线帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每个方法从调用直至执行完成的过程,都对应着一个线帧在虚拟机栈中入栈到出栈的过程。


特性:内存私有,它的生命周期和线程相同。


异常规定:StackOverflowError、OutOfMemoryError

1、如果线程请求的栈深度大于虚拟机所允许的栈深度就会抛出StackOverflowError异常。

2、如果虚拟机是可以动态扩展的,如果扩展时无法申请到足够的内存就会抛出OutOfMemoryError异常。

本地方法栈

本地方法栈(Native Method Stack)与虚拟机栈的作用是一样的,只不过虚拟机栈是服务Java方法的,而本地方法栈是为虚拟机调用Native方法服务的。

在Java虚拟机规范中对于本地方法栈没有特殊的要求,虚拟机可以自由的实现它,因此在Sun HotSpot虚拟机直接把本地方法栈和虚拟机栈合二为一了。

Java堆

Java堆(Java Heap)是Java虚拟机中内存最大的一块,是被所有线程共享的,在虚拟机启动时候创建,Java堆唯一的目的就是存放对象实例,几乎所有的对象实例都在这里分配内存,随着JIT编译器的发展和逃逸分析技术的逐渐成熟,栈上分配、标量替换优化的技术将会导致一些微妙的变化,所有的对象都分配在堆上渐渐变得不那么“绝对”了。

特性:内存共享

异常规定:OutOfMemoryError

如果在堆中没有内存完成实例分配,并且堆不可以再扩展时,将会抛出OutOfMemoryError。

Java虚拟机规范规定,Java堆可以处在物理上不连续的内存空间中,只要逻辑上连续即可,就像我们的磁盘空间一样。在实现上也可以是固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是可扩展的,通过-Xmx和-Xms控制。

方法区

方法区(Methed Area)用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。

误区:方法区不等于永生代

很多人原因把方法区称作“永久代”(Permanent Generation),本质上两者并不等价,只是HotSpot虚拟机垃圾回收器团队把GC分代收集扩展到了方法区,或者说是用来永久代来实现方法区而已,这样能省去专门为方法区编写内存管理的代码,但是在Jdk8也移除了“永久代”,使用Native Memory来实现方法区。

特性:内存共享

异常规定:OutOfMemoryError

当方法无法满足内存分配需求时会抛出OutOfMemoryError异常

运行时常量池

运行时常量池是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table)用于存放编译期生成的各种字面量和符号引用,这部分在类加载后进入方法区的运行是常量池中,如String类的intern()方法。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,但这部分内存也会被频繁的使用,而且可能导致OutOfMemoryError。在JDK 1.4中新加入了NIO类,引入了一种基于Channel与缓冲区Buffer的IO方式,它通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用操作,它因此更高效,它避免了Java堆和Native堆来回交换数据的时间。

注意 :直接内存分配不会受到Java堆大小的限制,但是受到本机总内存大小限制,在设置虚拟机参数的时候,不能忽略直接内存,把实际内存设置为-Xmx,使得内存区域的总和大于物理内存的限制,从而导致动态扩展时出现OutOfMemoryError异常。


什么是是线程不可见性?

基于以上当我们定义一个static 关键字所修饰的变量的时候; 本身会在 jvm 虚拟机的运行时数据区内的方法区开辟一块儿空间来存放这个共享的值,所以当我们开启一个线程的时候会复制一份里边的内容到自己的栈空间里边,若是这个值没有改变则不会去刷新方法区内的共享内容, 若是发生了改变则会立刻刷新这个共享的值;所以当两个线程同时执行的时候(每个线程都有自己独立的栈空间) A线程已经改变了共享的值,但是B线程早在A线程往方法区刷新之前就读取了之前的值,出现脏读的现象; 这就是线程之间的不可见性

volatile可以说是java虚拟机提供的最轻量级的同步机制。但它同时不容易被正确理解;

java内存模型告诉我们,各个线程会将共享变量从主内存中拷贝到工作内存, 然后执行引擎会基于工作内存中的数据进行操作处理;


线程在工作内存进行操作后何时会写到主内存中?

这个时机对普通变量是没有规定的,而针对volatile修饰的变量给java虚拟机特殊的约定,线程对volatile变量的修改会立刻被其他线程所感知,即不会出现数据脏读的现象,从而保证线程数据的“可见性”。

大概的意思就是:被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。



volatile实现原理

在生成汇编代码时会在volatile修饰的共享变量进行写操作的时候会多出Lock前缀的指令(书上说的我也不知道)


那么Lock前缀的指令在多核处理器下会发现什么事情了?

主要有这两个方面的影响:

1.将当前处理器缓存行的数据写回系统内存;

2.这个写回内存的操作会使得其他CPU里缓存了该内存地址的数据无效

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存 (L1,L2或其他) 后再进行操作,但操作完不知道何时会写到内存。

如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现

缓存一致性

协议,

每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期

了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。因此,经过分析我们可以得出如下结论:

  1. Lock前缀的指令会引起处理器缓存写回内存;
  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存失效;
  3. 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。

这样针对volatile变量通过这样的机制就使得每个线程都能获得该变量的最新值。



版权声明:本文为metjoyful原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。