JVM与JMM
JVM(java虚拟机)
Java虚拟机在运行程序时会把它管理的内存划分为以上几个区域,每个区域都有各自的用途以及销毁时机。 蓝色区域代表所有线程共享的数据区域,而绿色部分代表的是各个线程的私有数据区域。
方法区(Method Area):
方法区属于线程共享的内存区域,又称Non-Heap(非堆),主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,根据Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError 异常。值得注意的是在方法区中存在一个叫运行时常量池(Runtime Constant Pool)的区域,它主要用于存放编译器生成的各种字面量和符号引用,这些内容将在类加载后存放到运行时常量池中,以便后续使用。
JVM堆(Java Heap):
Java 堆也是属于线程共享的内存区域,它在虚拟机启动时创建,是Java 虚拟机所管理的内存中最大的一块,主要用于存放对象实例,几乎所有的对象实例都在这里分配内存,注意Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做GC 堆,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。
程序计数器(Program Counter Register):
程序计数器属于线程私有的数据区域,是一小块内存空间,主要代表当前线程所执行的字节码行号指示器。字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
虚拟机栈(Java Virtual Machine Stacks):
虚拟机栈属于线程私有的数据区域,与线程同时创建,总数与线程关联,代表Java方法执行的内存模型。每个方法执行时都会创建一个栈桢来存储方法的的变量表、操作数栈、动态链接方法、返回值、返回地址等信息。每个方法从调用到结束就对应于一个栈桢在虚拟机栈中的入栈和出栈过程。
本地方法栈(Native Method Stacks):
本地方法栈属于线程私有的数据区域,这部分主要与虚拟机用到的 Native 方法相关,一般情况下,我们无需关心此区域。
JMM(Java内存模型)
概念
JVM规范中定义了Java内存模型(抽象概念),用于屏蔽掉各种硬件和OS的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。
JMM规范了JVM与计算机内存之间是如何协同工作的,规定了一个线程在何时如何看到由其他线程修改后的变量的值,以及如何在必要时同步的访问共享变量。
需要注意的是,JMM与Java内存区域唯一相似点,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存属于线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。或许在某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,实际上他们表达的都是同一个含义。
主内存与工作内存
主内存
主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。
工作内存
主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。
JMM与硬件内存架构
硬件内存架构
就目前计算机而言,一般拥有多个CPU并且每个CPU可能存在多个核心,多核是指在一枚处理器(CPU)中集成两个或多个完整的计算引擎(内核),这样就可以支持多任务并行执行,从多线程的调度来说,每个线程都会映射到各个CPU核心中并行运行。在CPU内部有一组CPU寄存器,寄存器是一个临时存放数据的空间。一般CPU都会从内存取数据到寄存器,然后进行处理,但由于内存的处理速度远远低于CPU,导致CPU在处理指令时往往花费很多时间在等待内存做准备工作,于是在寄存器和主内存间添加了CPU缓存,CPU缓存比较小,但访问速度比主内存快得多,如果CPU总是操作主内存中的同一地址的数据,很容易影响CPU执行速度,此时CPU缓存就可以把从内存提取的数据暂时保存起来,如果寄存器要取内存中同一位置的数据,直接从缓存中提取,无需直接从主内存取。需要注意的是,寄存器并不每次数据都可以从缓存中取得数据,万一不是同一个内存地址中的数据,那寄存器还必须直接绕过缓存从内存中取数据。所以并不每次都得到缓存中取数据,这种现象有个专业的名称叫做缓存命中率,从缓存中取就命中,不从缓存中取从内存中取,就没命中,可见缓存命中率的高低也会影响CPU执行性能,这就是CPU、缓存以及主内存间的简要交互过程,总而言之当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存(当然如果CPU缓存中存在需要的数据就会直接从缓存获取),进而在读取CPU缓存到寄存器,当CPU需要写数据到主存时,同样会先刷新寄存器中的数据到CPU缓存,然后再把数据刷新到主内存中。
java线程与硬件处理器
理解线程的实现原理,有助于我们了解Java内存模型与硬件内存架构的关系,在Window系统和Linux系统上,Java线程的实现是基于一对一的线程模型,所谓的一对一模型,实际上就是通过语言级别层面程序去间接调用系统内核的线程模型,即我们在使用Java线程时,Java虚拟机内部是转而调用当前操作系统的内核线程来完成当前任务。这里需要了解一个术语,内核线程(Kernel-Level Thread,KLT),它是由操作系统内核(Kernel)支持的线程,这种线程是由操作系统内核来完成线程切换,内核通过操作调度器进而对线程执行调度,并将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这也就是操作系统可以同时处理多任务的原因。由于我们编写的多线程程序属于语言层面的,程序一般不会直接去调用内核线程,取而代之的是一种轻量级的进程(Light Weight Process),也是通常意义上的线程,由于每个轻量级进程都会映射到一个内核线程,因此我们可以通过轻量级进程调用内核线程,进而由操作系统内核将任务映射到各个处理器,这种轻量级进程与内核线程间1对1的关系就称为一对一的线程模型。每个线程最终都会映射到CPU中进行处理,如果CPU存在多核,那么一个CPU将可以并行执行多个线程任务。
JMM与硬件内存架构的关系
Java内存模型和硬件内存架构并不完全一致。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。(注意对于Java内存区域划分也是同样的道理)。
JMM存在的必要性
主内存中存在一个共享变量x,现在有A和B两条线程分别对该变量x=1进行操作,A/B线程各自的工作内存中存在共享变量副本x。假设现在A线程想要修改x的值为2,而B线程却想要读取x的值,那么B线程读取到的值是A线程更新后的值2还是更新前的值1呢?答案是,不确定,即B线程有可能读取到A线程更新前的值1,也有可能读取到A线程更新后的值2,这是因为工作内存是每个线程私有的数据区域,而线程A变量x时,首先是将变量从主内存拷贝到A线程的工作内存中,然后对变量进行操作,操作完成后再将变量x写回主内,而对于B线程的也是类似的,这样就有可能造成主内存与工作内存间数据存在一致性问题,假如A线程修改完后正在将数据写回主内存,而B线程此时正在读取主内存,即将x=1拷贝到自己的工作内存中,这样B线程读取到的值就是x=1,但如果A线程已将x=2写回主内存后,B线程才开始读取的话,那么此时B线程读取到的就是x=2,但到底是哪种情况先发生呢?这是不确定的,这也就是所谓的线程安全问题。
为了解决类似上述的问题,JVM定义了一组规则,通过这组规则来决定一个线程对共享变量的写入何时对另一个线程可见,这组规则也称为Java内存模型(即JMM),JMM是围绕着程序执行的原子性、有序性、可见性展开的,下面我们看看这三个特性。
内存交互操作
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存以及从工作内存中同步到主内存之间的实现细节。Java内存模型定义了8种操作来完成。
lock-加锁
作用于主内存变量,把一个变量标示为一条线程独占状态
read-读取
作用于主内存变量,把一个变量值从主内存传输到工作内存中
load-载入
作用于工作内存的变量,把从主内存read到的变量值载入到工作内存的变量副本中
use-使用
作用于工作内存的变量,把变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量值得字节码指令时就会执行这个操作
assign-赋值
作用于工作内存的变量,把从工作引擎接收到的值赋值给工作内存变量,每当虚拟机遇到一个需要给变量赋值的字节码指令时就会执行此操作
store-存储
作用于工作内存的变量,把工作内存中的变量值传送到主内存中
write-写入
作用于主内存中的变量,把从工作内存传送得到的值写入主内存的变量中
unlock-解锁
作用于主内存变量,把一个处于锁定状态的变量释放出来
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rwI7dB5y-1646906793599)(E:\file\学习\课后总结\并发编程\并发编程-总结版.assert\image-20220220220133115.png)]
Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
- 如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
- 不允许read和load、store和write操作之一单独出现
- 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。
JMM的三大特性
并发编程的bug源头
原子性
概念
原子性就是指线程执行任务时,中间不可以被加塞或者分割, 需要整体完成,要么同时成功,要么同时失败。
比如对于一个静态变量int x,两条线程同时对他赋值,线程A赋值为1,而线程B赋值为2,不管线程如何运行,最终x的值要么是1,要么是2,线程A和线程B间的操作是没有干扰的,这就是原子性操作,不可被中断的特点。有点要注意的是,对于32位系统的来说,long类型数据和double类型数据(对于基本数据类型,byte,short,int,float,boolean,char读写是原子操作),它们的读写并非原子性的,也就是说如果存在两条线程同时对long类型或者double类型的数据进行读写是存在相互干扰的,因为对于32位虚拟机来说,每次原子读写是32位的,而long和double则是64位的存储单元,这样会导致一个线程在写时,操作完前32位的原子操作后,轮到B线程读取时,恰好只读取到了后32位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能是“半个变量”的数值,即64位数据被两个线程分成了两次读取。但也不必太担心,因为读取到“半个变量”的情况比较少见,至少在目前的商用的虚拟机中,几乎都把64位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意。
如何保证原子性
- 通过 synchronized 关键字
由JMM直接保证的原子性变量操作包括read、load、assign、use、store和write这六个,基本数据类型的访问读写都是具备原子性的(例外就是long和double的非原子性协定)。如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足需求,尽管虚拟机还没有把lock和unlock开放给用户使用,但却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作。这两个字节码指令反映到Java代码中就是同步块synchronized关键字,因此synchronized具有原子性
- 通过 Lock
- 通过 CAS
JVM中的CAS操作利用了处理器提供的交换指令CMPXCHG实现,自旋CAS的基本思路就是循环进行CAS操作直到成功为止。从Java1.5开始JDK的并发包里提供了一些类来支持原子操作,例如AtomicBoolean(用原子方式更新的boolean值),AtomicInteger(用原子方式更新的int值)和AtomicLong(用原子方式更新的long值),这些原子包装类还提供了有用的工具方法,比如以原子的方式将当前值自增1和自减1。
可见性
概念
当一个线程修改了共享变量的值,其他线程能够看到修改的值。
Java 内存模型是通过在变量 修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介 的方法来实现可见性的。
如何保证可见性
- 通过volatile关键字
- 通过内存屏障
- 通过synchronized关键字
- 通过lock
- 通过final关键字
有序性
即程序执行的顺序按照代码的先后顺序执行。
JVM 存在指令重排,所以存在有序性问题。
如何保证有序性
- 通过volatile 关键字保证可见性
- 通过内存屏障保证可见性
- 通过synchronized关键字保证有序性
- 通过Lock保证有序性
as-if-serial
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C
A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序 列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之 间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。
happens-before
从JDK 5 开始,JMM使用happens-before的概念来阐述多线程之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happensbefore关系。
happens-before和JMM关系如下图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-N9ccZ82f-1646906793600)(E:\file\学习\课后总结\并发编程\assert\image-20220310111449197.png)]
happens-before原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依
靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题。下面我们就一个简单的例子稍微了解下happens-before :
i = 1;//Thread1
j = i;//Thread2
j 是否等于1呢?假定线程A的操作(i = 1)happens-before线程B的操作(j = i),那么可以 确定线程B执行后j = 1 一定成立,如果他们不存在happens-before原则,那么j = 1 不一定成 立。这就是happens-before原则的威力。
happens-before原则定义如下
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作 可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则 制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果 一致,那么这种重排序并不非法。
下面是happens-before原则规则
1.程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操 作;
2.锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作;
3.volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
4.传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A 先行发生于操作C;
5.线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
6.线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件 的发生;
7.线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
8.对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;
我们来详细看看上面每条规则(摘自《深入理解Java虚拟机第12章》):
- 程序次序规则:一段代码在单线程中执行的结果是有序的。注意是执行结果,因为虚拟机、处理 器会对指令进行重排序。虽然重排序了,但是并不会影响程序的执行结果,所以程序最终执行的 结果与顺序执行的结果是一致的。故而这个规则只对单线程有效,在多线程环境下无法保证正确 性。
- 锁定规则:这个规则比较好理解,无论是在单线程环境还是多线程环境,一个锁处于被锁定状 态,那么必须先执行unlock操作后面才能进行lock操作。
- volatile变量规则:这是一条比较重要的规则,它标志着volatile保证了线程可见性。通俗点讲就 是如果一个线程先去写一volatile变量,然后一个线程去读这个变量,那么这个写操作一定是 happens-before读操作的。
- 传递规则:提现了happens-before原则具有传递性,即A happens-before B , B happensbefore C,那么A happens-before C
- 线程启动规则:假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对 共享变量的修改在接下来线程B开始执行后确保对线程B可见。
- 线程终结规则:假定线程A在执行的过程中,通过制定ThreadB.join()等待线程B终止,那么线程B 在终止之前对共享变量的修改在线程A等待返回后可见。
上面八条是原生Java满足Happens-before关系的规则,但是我们可以对他们进行推导出其他满足happens-before的规则:
- 1.将一个元素放入一个线程安全的队列的操作Happens-Before从队列中取出这个元素的操作
- 2.将一个元素放入一个线程安全容器的操作Happens-Before从容器中取出这个元素的操作
- 3.在CountDownLatch上的倒数操作Happens-Before CountDownLatch#await()操作
- 4.释放Semaphore许可的操作Happens-Before获得许可操作
- 5.Future表示的任务的所有操作Happens-Before Future#get()操作
- 6.向Executor提交一个Runnable或Callable的操作Happens-Before任务开始执行操作
这里再说一遍happens-before的概念:如果两个操作不存在上述(前面8条 + 后面6条)任一一 个happens-before规则,那么这两个操作就没有顺序的保障,JVM可以对这两个操作进行重排 序。如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的。
下面就用一个简单的例子来描述下happens-before原则:
private int i = 0;
public void write(int j){
i = j;
}
public int read(){
return i;
}
我们约定线程A执行write(),线程B执行read(),且线程A优先于线程B执行,那么 线程B获得结果是什么?我们就这段简单的代码一次分析happensbefore的规则 (规则5、6、7、8 + 推导的6条可以忽略,因为他们和这段代码毫无关系):
- 由于两个方法是由不同的线程调用,所以肯定不满足程序次序规则;
- 两个方法都没有使用锁,所以不满足锁定规则;
- 变量i不是用volatile修饰的,所以volatile变量规则不满足;
- 传递规则肯定不满足;
所以我们无法通过happens-before原则推导出线程A happens-before 线程B,虽然可以确认在时间上线程A优先于线程B指定,但是就是无法确认线程B获得的结果是什 么,所以这段代码不是线程安全的。那么怎么修复这段代码呢?满足规则2、3任一即 可。
happens-before原则是JMM中非常重要的原则,它是判断数据是否存在竞争、线程是否安全的 主要依据,保证了多线程环境下的可见性。
用锁,所以不满足锁定规则;
- 变量i不是用volatile修饰的,所以volatile变量规则不满足;
- 传递规则肯定不满足;
所以我们无法通过happens-before原则推导出线程A happens-before 线程B,虽然可以确认在时间上线程A优先于线程B指定,但是就是无法确认线程B获得的结果是什 么,所以这段代码不是线程安全的。那么怎么修复这段代码呢?满足规则2、3任一即 可。
happens-before原则是JMM中非常重要的原则,它是判断数据是否存在竞争、线程是否安全的 主要依据,保证了多线程环境下的可见性。