1、
JVM内存结构
(1)Java代码是运行在虚拟机上的,而虚拟机在执行Java程序的过程中会把管理的
内存划分为若干个不同的
数据区域。其中有些区域是随着虚拟机进程的启动而存在,而有些区域则依赖用户线程的启动和结束而建立和销毁。JVM运行时内存区域结构如下:
程序计数器:指向当前线程所执行的字节码指令的地址,通常也叫做行号指示器。
Java虚拟机栈:
虚拟机栈描述的是java方法执行的内存模型,方法的执行的同时会创建一个栈祯,用于存储方法中的局部变量表、操作数栈、动态链接、方法的出口等信息,每个方法从调用直到执行完成的过程,就对应着一个栈祯在虚拟机栈中入栈到出栈的过程
。
本地方法栈:
本地方法栈和虚拟机栈很相似,区别在于而本地方法栈是为Native方法服务而虚拟机栈是为java方法服务,在本地方法栈也会抛出StackOverFlowError异常、OutOfMemoryError异常。
Java堆:
java堆是Java虚拟机所管理的内存中最大的一块。java堆是被所有线程所共享的一块内存区域,虚拟机启动时创建,几乎所有对象的实例都存储在堆中,所有的对象和数组都要在堆上分配内存。
java堆是垃圾收集器(GC)管理的主要区域。
方法区:
方法区与堆一样所有线程所共享的内存区域,它用于存储已
被虚拟机加载的类信息、常量、静态变量、及时编译器编译后的代码等数据
。java虚拟机对方法区的限制非常宽松,除了和堆一样不需要连续的内存和可扩展,还可以不实现垃圾收集,相对而言,垃圾收集机制在这个区域出现的较少,当方法区无法分配足够内存时,将会抛出OutOfMemoryError异常。
运行时常量池:
运行时常量池是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口、等描述信息外,还有一项信息就是常量池,用于存放编译时期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。一般来说,除了保存Class文件中的描述符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。
运行时常量池相对于Class文件常量池的另一个重要特征是具备动态性,java语言并不要求常量一定只有编译时期才能产生,也就是说并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入常量池,用的较多的如String的intern()方法。
运行时常量池是方法区的一部分,自然当方法区无法分配足够内存时,将会抛出OutOfMemoryError异常。
2、
Java内存模型:JMM(Java Memory Model)只是一个
抽象的概念,并不是真实存在。
(1)Java堆和方法区是多个
线程共享的数据区域,也就是说多个线程可能操作保存在堆或者方法区中的同一个数据,也就是“
Java的多线程间通过共享内存进行通信”。
(2)由于Java多线程之间是通过共享内存通信的,在通信过程中就会存在一系列如
可见性、
原子性、
顺序性等问题,而
JMM就是围绕着多线程通信及其相关的一系列特性而建立的模型。JMM定义了一些语法集,这些语法集映射到Java语言中就是:
volatile、
synchronized等关键字。
(3)在JMM中将共享内存称之为
主内存,而在并发编程中的多个线程都维护了一个自己的
本地内存(抽象概念),保存的数据是主内存的拷贝。
JMM主要就是控制本地内存和主内存之间的数据交互。
3、
Java对象模型:Java是一种面向对象的语言,而Java对象在JVM中的存储也是有一定的结构的。而这个关于
Java对象自身的存储模型称之为Java对象模型。
(1)HotSpot虚拟机中,设计了
OOP-KlassModel,其中OOP是指普通对象指针,而Klass用来描述对象实例的具体类型。
(2)每个Java类再
被JVM加载的时候,JVM会给该类创建一个
instanceKlass,保存在
方法区,用来在JVM层次表示该Java类。当我们在Java代码中通过
new创建一个对象时,JVM会在
堆中创建一个
instanceOopDesc对象,这个对象包含了
对象头及
实例数据。对象头中的
元数据指针指向
方法区中的instanceKlass。在Java虚拟机
栈中创建
对象的引用,指向
堆中
对象的对象头。
对象头中包含了GC分代年龄、锁状态标记、哈希码、epoch等信息。对象的状态一共有五种:无状态、轻量级锁、重量级锁、GC标记、偏向锁。
4、总结:
JVM的内存结构:和
Java虚拟机的运行时区域有关
Java的内存模型:和
Java并发编程有关
Java的对象模型:和
Java对象在虚拟机中的表现形式有关
5、Java内存模型详述
(1)JMM和硬件的关系
1)CPU和缓存一致性
计算机在执行程序的时候,每条指令都是在CPU中执行的,在执行的时候涉及到的数据存储在主存(计算机物理内存)中。随着CPU技术的发展,CPU执行速度越来越快,但是内存技术没有太大发展,导致CPU每次操作内存都需要耗费很多等待时间。
于是,在CPU和内存之间增加了高速缓存。因此程序执行过程就变成:当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存中,CPU计算就直接从高速缓存中读取和写入数据,当运算结束之后再将高速缓存中的数据刷新到主存当中。
随着CPU能力的不断提升,一层缓存慢慢不能满足要求,就逐渐衍生出多级缓存。当CPU要读取一个数据时,先从一级缓存中查找,没找到再从二级缓存中查找,若还没找到,再从三级缓存中查找。单核CPU只含一套L1、L2、L3缓存,多核CPU包含多套。
单线程:CPU核心的缓存只被一个线程访问,缓存独占,不会出现访问冲突。
单核CPU,多线程:由于任何时刻只有一个线程在执行,也不会出现访问冲突。
多核CPU,多线程:由于多核可以并行,可能会出现多个线程同时写各自的缓存的情况,就可能存在缓存一致性问题。同一个数据的缓存内容可能不一致。
2)处理器优化和指令重排序
为了使处理器内部的运算单元能够尽量被利用,处理器可能会对输入的代码进行乱序执行处理,即处理器优化。
很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器JIT也会做指令重排序。
(2)并发编程的问题:
原子性、可见性、有序性问题。这些问题是人们抽象出来的,这些抽象问题的底层就是上边提到的:缓存一致性问题、处理器优化问题和指令重排序问题。
1)原子性:一个操作不可被CPU中断再进行调度,要么一次性完成,要么不做。——>处理器优化问题
2)可见性:多线程访问同一个变量时,一个线程修改了变量的值,其他线程能立即看到修改之后的值。——>缓存一致性问题
3)有序性:程序执行按照代码先后顺序执行。——>指令重排问题
(3)什么是内存模型:为了保证共享内存的正确性,内存模型定义了共享内存系统中多线程程序读写操作行为规范。内存模型解决并发问题主要采取两种方式:
限制处理器优化和
使用内存屏障。
(4)什么是Java内存模型:就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。一般指JDK5之后的新内存模型,有JSR-133规范。
Java内存模型规定:所有变量都存储到
主内存中,每条线程还有自己的
工作内存,工作内存中的数据是主内存的拷贝,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存。不同线程之间也无法访问对方的工作内存,
线程之间的变量传递均需要通过自己的工作内存和主内存进行数据同步。
Java内存模型就工作于工作内存和主内存之间数据同步过程。规定了如何做数据同步以及什么时候做数据同步。
总结:
JMM是一种规范,目的是解决多线程间共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。目的是保证并发编程场景下的原子性、可见性、有序性。
(5)Java内存模型的实现
1)
volatile、
synchronized、
final、
concurrent包等就是Java内存模型封装了底层实现后提供给程序员使用的一些关键字。
2)Java内存模型除了定义一套规范,还提供了一系列原语,封装了底层实现后,提供给开发者使用。
(6)原子性、可见性、有序性问题解决方案
1)
原子性:通过synchronized关键字解决,保证方法和代码块内的操作是原子性的。
2)
可见性:通过volatile关键字解决,synchronized和final也可以解决可见性。
3)
有序性:通过synchronized或volatile保证有序性。
volatile关键字会禁止指令
重
排序,而synchronized保证
同一时刻只允许一条线程操作。
(7)
synchronized的实现原理:
1)Java中,synchronized有
同步方法和
同步代码块两种使用方式
2)反编译后可以看到字节码文件中JVM对于同步方法和同步代码块的实现原理不相同。
3)
同步方法时,通过
ACC_SYNCHRONIZED标记符来实现同步。当一个线程要访问某个方法时,会检查是否有ACC_SYNCHRONIZED,如果有设置,则需要先获取到监视器锁,然后再开始执行方法,方法执行完后在释放监视器锁。如果此时其他线程来请求此方法,会因为无法获取到监视器锁而被阻塞。值得注意的是,如果在执行方法时
发生了异常并且方法内部未处理该异常,在
异常被抛到方法外之前监视器锁会自动释放。
4)
同步代码块时,通过
monitorenter和
monitorexit两个指令来实现同步。执行monitorenter指令表示加锁,执行monitorexit指令表示解锁。每个对象维护着被锁次数的计数器,未被锁定的对象,计数器为0。
(8)
synchronized与原子性
线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。当一个线程获取到时间片之后开始执行,在时间片耗尽之后就会失去CPU使用权。所以在多线程场景下,由于
时间片在线程间轮换,就会出现原子性问题。
通过monitorenter和monitorexit两个指令可以保证被synchronized修饰的代码同一时间只有一个线程可以访问,保证了方法和代码内的操作是原子性的。
线程1在执行monitorenter指令的时候,会对Monitor进行加锁,加锁后其他线程无法获取到锁,除非线程1主动解锁。即使CPU时间片用完,线程以1放弃了CPU,但是也并没有进行解锁。而由于synchronized是可重入锁,下一个时间片还是只能被他自己获取到锁,继续执行代码直到所有代码执行完,这就保证了原子性。
(9)
synchronized与可见性
为了保证可见性,有一条规则:
对一个变量解锁之前,必须先把变量同步会主内存中。这样解锁之后,后续线程就可以访问被修改后的值。所以被synchronized关键字锁住的对象,其值具有可见性。
(10)
synchronized与有序性
需要注意的是synchronized是无法禁止指令重排序和处理器优化的。但是synchronized同样提供了有序性保证。
Java程序中:如果在本线程内观察,所有操作都是天然有序的。如果在一个线程中观察另一个线程,所有操作都是无序的。这其实和
as-if-serial语义有关。
as-if-serial语义:不管怎么重排序,单线程程序的
执行结果都不能被改变,编译器和处理器无论如何优化,都必须遵守as-if-serial语义。
由于synchronized修饰的代码同一时间只能被同一线程访问,也就是单线程执行的,所以可以保证其有序性。
(11)
Monitor(管程,也成为监视器)的实现原理
synchronized无论是同步方法还是同步代码块都是
基于Monitor实现的。在多线程访问共享资源时,经常会带来可见性、原子性等安全问题,为此Java提供了
同步机制、互斥锁机制来保证同一时间只有一个线程能访问共享资源。这个机制的原理就是监视器Monitor,
每个对象都拥有自己的监视器锁Monitor。
监视器Monitor的实现:在Java虚拟机HotSpot中,Monitor是基于C++实现的,由
ObjectMonitor实现。关键属性:
_owner:指向持有ObjectMonitor对象的线程
_WaitSet:存放处于wait状态的线程
_EntryList:存放处于等待锁block状态的线程
_recursions:锁的重入次数
_count:记录该线程获取锁的次数
当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_owner区域并把monitor中的_owner变量设置为当前线程,同时计数器加1。
若持有monitor的线程调用wait()方法,将释放当前monitor,_owner变量恢复为null,_count减1,同时该线程进入_WaitSet集合等待被唤醒。若当前线程执行完也将释放monitor,以便于其他线程获取monitor锁。
总结:
synchronized加锁的时候会调用ObjectMonitor的enter方法,解锁的时候会调用exit方法。事实上只有在
JDK1.6之前,synchronized的实现才是这样的,这种锁也被称之为重量级锁。因为Java的线程是
直接映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统帮忙,需要从
用户态切换到核心态,状态转换需要花费很长时间,对于同步简单的代码块状态转换消耗的时间有可能比代码执行时间还长,所以为重量级锁。
在JDK1.6之后对锁进行了很多优化,进而出现了
轻量级锁、
偏向锁、
锁消除、
适应性自旋锁、
锁粗化。
12、
Java虚拟机的锁优化技术
(1)作为Java开发,只需要知道你想在加锁的时候使用synchronized就可以了,具体的锁的优化是虚拟机根据竞争情况自行决定的。
(2)
自旋锁
Java虚拟机的开发工程师们在分析过大量数据后发现:
共享数据的锁定状态一般只会持续很短一段时间,为了这段时间去挂起和恢复线程其实并不值得。
如果物理机上有多个处理器,可以让多个线程同时执行。就可以让后面来的线程“稍微等一下”,但是并
不会放弃处理器的执行时间,看看持有锁的线程会不会很快的释放锁。这个“稍微等一下”的过程就是自旋。自旋锁在JDK1.4中已经引入,在
JDK1.6默认开启。
当线程数不断增加,性能下降会十分明显,因为每个线程都需要执行,占用CPU时间。只有线程竞争不激烈,并且保持锁的时间段,才适合使用自旋锁。
自旋锁和阻塞锁的区别
最大区别就是
自旋锁不放弃CPU时间,一直“自旋”在那里,时刻检查共享资源是否可以被访问。而阻塞锁放弃了CPU时间,进入等待区等待被唤醒。
(3)
锁消除
:动态编译同步块时,
JIT编译器
可以借助
逃逸分析技术
判断同步块所使用的对象是否只能被一个线程访问而没有被发布到其他线程。如果被证实只能被一个线程访问,就会对这部分代码进行锁消除。
public void f(){
Object a = new Object();
synchronized(a){
System.out.println(a);
}
}
f方法中对对象a进行加锁,但是a对象的生命周期只在f()方法中,不会被其他线程访问到,所以会被锁消除。优化为:
public void f(){
Object a = new Object();
System.out.println(a);
}
(4)
锁粗化:当JIT发现一系列连续的操作都对同一个对象反复进行加锁和解锁时,会将加锁范围粗化到整个操作序列之外。如:
for(int i=0;i<10000;i++){
synchronized(this){
System.out.println(i);
}
}会被粗化成:
synchronized(this){
for(int i=0;i<10000;i++){
System.out.println(i);
}
}
(5)总结:自Java6/Java7开始,Java虚拟机对内部锁的实现进行了优化。主要包括:锁消除、锁粗化、偏向锁、以及适应性自旋锁。这些
优化仅在Java虚拟机server模式下起作用(即运行Java程序时需要在命令行中指定Java虚拟机参数 “-server”以开启这些优化)。
13、
volatile详述
(1)volatile用法:“轻量级的synchronized”,
只能用来修饰变量。
(2)
volatile原理:
上文提到过,为了提高处理器的执行速度,在处理器和内存之间增加了多级缓存来进行提升。但同样会出现缓存数据不一致问题。对于
volatile变量进行写操作时,
JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。但是多处理器场景下,其他处理器中的缓存值依然是旧的,执行计算就会有问题,所以在多处理器下,为保证各个处理器的缓存是一致的,就会实现
缓存一致性协议:
每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是否过期,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对数据进行修改操作时,会强制从系统内存把数据读到处理器缓存中。
所以,如果一个变量被volatile修饰后,每次数据变化之后,其值都会被强制刷入主存。而其他处理器缓存由于遵守了缓存一致性协议,就会把这个变量的值从主存加载到自己的缓存中,保证了多个缓存中是可见的。因此可以使用volatile来保证多线程操作时变量的可见性。
(3)volatile与有序性
volatile可以禁止指令冲排,这就保证了程序严格按照代码的先后顺序执行。
(4)volatile与原子性
原子性需要字节码指令monitorenter和monitorexit,但是volatile和这两个指令没有关系,所以volatile是不能保证原子性的。
(5)
volatile是如何禁止指令
重
排的呢?
volatile是通过内存屏障来禁止指令
重
排的。
内存屏障(Memory Barrier):是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,达到效果:此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。
volatile写之前的操作不会被重排序到volatile写之后;
volatile读之后的操作不会被重排序到volatile读之前;
第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
(6)缓存可见性和并发编程中的可见性可以互相类比,但他们不是一回事儿
(7)既生synchronized,何生volatile?
1)synchronized是一种加锁机制,有如下缺点:
有性能损耗:在同步操作前要加锁,同步操作之后要释放锁
产生阻塞:synchronized无论是同步方法还是同步代码块都是基于Monitor实现的。
当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_owner区。
若持有monitor的线程调用wait()方法,将释放当前monitor,同时该线程进入_WaitSet集合等待被唤醒。若当前线程执行完也将释放monitor,以便于其他线程获取monitor锁。
2)volatile有附加功能——禁止指令重排
由于指令重排导致获取到不完整的singleton对象,在使用该对象时就会产生NPE异常:
singleton = new Singleton();可以简化为:
JVM为对象分配一块内存;
在内存上为对象进行初始化;
将内存地址复制给singleton变量;
上述步骤经过编译器重排序后可变成:
JVM为对象分配一块内存;
将内存地址复制给singleton变量;
在内存上为对象进行初始化;