浅入理解jvm

目录

自动内存管理机制

对象的内存布局

垃圾回收机制

可达性分析

垃圾收集器

内存分配与回收策略 

虚拟机性能监控工具

类加载

new一个对象发生了什么

栈帧

类加载器

常量池

变量和线程安全

java内存模型

volatile

线程安全的实现方法

锁优化


  • 自动内存管理机制

程序计数器区是唯一没有内存溢出的区域,hotspot通过使用直接指针访问方法区的对象类型数据。

  • 对象的内存布局

分为三部分:对象头、实例数据、对齐填充。

对象头包括两部分:第一部分存储对象的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;第二部分是类型指针,即对象指向它的类元数据的指针,如果对象是java数组,对象头信息中还有一块记录数组长度的数据。

  • 垃圾回收机制

新生代采用标记复制算法,老年代采用标记清除(cms)或标记整理算法。对象首先在eden区分配,当Eden区没有足够空间进行分配时,虚拟机会发起一次 Minor GC,把存活的对象复制到to space区域,如果to space区域不够,则利用担保机制进入老年代区域。

  • 可达性分析

通过可达性分析判断对象是否存活,当一个对象到GC Roots没有任何引用链相连证明对象是不可用的。GC Roots对象包括虚拟机栈中引用的对象(即正在运行的方法中引用的对象)、方法区中静态属性或常量引用的对象、本地方法栈中引用的对象。

  • 垃圾收集器

serial是默认的新生代收集器,但会stop the world带来不良体验。parnew仅仅是多线程,公司目前使用,多核下优于serial。新生代parallel scavenge不做分析,关注吞吐量。老年代有serial old,parallel old,cms。cms注重最短回收停顿时间,思想体现在回收线程与用户线程并行。在注重吞吐量和cpu资源敏感的场合优先考虑parallel scavenge和parallel old组合。目前公司采用parnew和cms组合,因为提供给客户端使用,交互性强,更倾向于垃圾回收时间最短。g1最先进但尚未成熟,思想是将堆划分为多个块,化整为零,避免全堆扫描,但由于各块之间对象互相引用,所以具体实现特别复杂。

"高吞吐量"和"低暂停时间"是一对相互竞争的目标(矛盾)。应用程序在GC期间必须停止(或者仅在GC的特定阶段,这取决于所使用的算法),然而这会增加额外的线程调度开销:直接开销是上下文切换,间接开销是因为缓存的影响。 加上JVM内部安全措施的开销,这意味着GC及随之而来的不可忽略的开销,将增加GC线程执行实际工作的时间。 因此我们可以通过尽可能少运行GC来最大化吞吐量,例如,只有在不可避免的时候进行GC,来节省所有与它相关的开销。然而,仅仅偶尔运行GC意味着每当GC运行时将有许多工作要做,因为在此期间积累在堆中的对象数量很高。 单个GC需要花更多时间来完成, 从而导致更高的平均和最大暂停时间。 因此,考虑到低暂停时间,最好频繁地运行GC以便更快速地完成。 这反过来又增加了开销并导致吞吐量下降,我们又回到了起点。综上所述,在设计(或使用)GC算法时​​,我们必须确定我们的目标:一个GC算法​​只可能针对两个目标之一(即只专注于最大吞吐量或最小暂停时间),或尝试找到一个二者的折衷。

  • cms收集过程

  1. 初始标记(第一次暂停)(老年代gcroots、被年轻代引用的老年代对象)
  2. 并发标记(从初始标记阶段标记的对象开始找出所有存活的对象)
  3. 重新标记(第二次暂停)(因为是并发运行的,在运行期间会发生新生代的对象晋升到老年代、或者是直接在老年代分配对象、或者更新老年代对象的引用关系等等)
  4. 并发清理
  • 内存分配与回收策略 

几种对象进入老年代的方法:大对象直接进入老年代,可以指定阈值。长期存活的对象进入老年代。空间分配担保(eden区放不下,触发young gc,eden区存活对象又大于survivor区,所以通过分配担保提前转移到老年代)。同龄对象总和大于survivor空间的一半也能进老年代。

  • 虚拟机性能监控工具

jps显示虚拟机进程,jstat定位性能问题的首选工具,可以显示类装载 内存 垃圾回收等运行数据,jinfo查看虚拟机参数,jmap生成堆转储快照,jstack生成当前时刻的线程快照。两种可视化工具jconsole和visualvm。

  • 类加载

类加载时机:new等指令、反射、父类未初始化等场景。类加载需要完成三件事:根据全限定名加载二进制流,静态存储结构转化为方法区运行时数据结构,生成class对象作为方法区数据访问入口。加载字节流可以从jar、zip等格式、网络、动态生成等方式。类编译为class文件时,方法会被编译成字节码指令,存放在一个名为code的属性里面。类加载后,会验证文件格式、元数据(如继承关系)、字节码(语义)、符号引用(权限等)。加载、验证后进入准备阶段,为类变量分配内存并设置初始值(不是初始化),常量直接初始化。

  • new一个对象发生了什么

如果是第一次使用此类,则使用双亲委派机制加载,分为加载、验证、准备、解析、初始化,此时类变量已经分配内存并初始化。创建对象首先在堆上分配内存、对实例变量赋默认值、初始化、在栈区定义引用变量并赋值给他堆上的地址。需要注意的是,无论是加载还是实例化,父类总比子类先执行,所以父类和子类的执行顺序是:父类静态变量和静态代码块,子类静态变量和静态代码块,父类变量和代码块,父类构造函数,子类变量和代码块,子类构造函数。

  • 栈帧

存储了方法的局部变量表、操作数栈、动态连接、方法返回地址等信息。静态方法和私有方法符合编译期可知,运行期不可变。静态分派的典型应用是方法重载,动态分派-重写。

  • 类加载器

两个类相等的前提条件是由同一个类加载器加载。启动类加载器加载lib目录下的类库,扩展类加载器加载lib\ext目录下的类库,应用程序类加载器加载用户类路径上的类库。双亲委派模型:当类加载器接收到加载类的请求时,自己先不加载,而是委托给父类加载器去完成,最终委托到启动类加载器,当父类搜不到需要加载的类时再由子类加载器去完成。线程上下文类加载器、热部署等破坏双亲委派模型。

  • 常量池

java中的常量池分为静态常量池和运行时常量池。静态常量池即class文件中的Constant pool,jvm加载类时,将class中的静态常量池保存到方法区形成运行时常量池。

  • 变量和线程安全

  1. 静态变量内存中只有一份所有对象共享,修改后会对其他对象有影响,线程不安全;
  2. 实例变量在单例模式下线程不安全,虽然是占用堆内存,但只有一份,spring的bean默认是单例模式,所以应小心添加实例变量,实例变量在非单例模式下是线程安全的,每个对象都在堆上有自己的内存;
  3. 局部变量线程安全,每个线程执行时把局部变量放在自己的栈帧的工作内存中,线程间不共享。
  • java内存模型

java内存模型用来屏蔽java程序在不同硬件或操作系统对内存访问的差异,规定了所有变量(主要是实例变量和静态变量这种能被共享的变量,不包括局部变量和方法参数这种线程私有的变量)都存储在主线程中,每条线程有自己的工作内存,保存该线程使用到的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行(通过lock、unlock、read、load、use、assign、store、write操作),而不能直接读写主内存的变量。

  • volatile

  1. 保证变量对所有线程的可见性,即当一个线程修改了变量值 ,新值对其他线程来说是立即得知的。线程修改变量并非把所有线程的变量都修改了,而是指线程使用加了volatile的变量时必须要先刷新。(保证不了原子性)
  2. 禁止指令重排(单例模式双重校验方式为什么需要使用volatile
  • 线程安全的实现方法

1、互斥同步(synchronized)

synchronized关键字在经过编译之后,会在同步块的前后形成monitor enter和monitor exit两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。Synchronized在JVM里的实现都是 基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。那什么是Monitor?可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。也就是通常说Synchronized的对象锁,MarkWord锁标识位为10,其中指针指向的是Monitor对象的起始地址。

由于java的线程是映射到操作系统的原生线程上的,如果要阻塞或唤醒一个线程,都需要从用户态转换到内核态,耗费很多的时间,因此synchronized是一个重量级的操作。互斥同步属于悲观策略。

ReentrantLock与synchronized很相似,都是可重入锁,ReentrantLock通过lock()和unlock()配合try/catch完成,主要有三个不同:等待可中断(线程等待锁的时候可以放弃等待)、公平锁(多个线程按照先后顺序获得锁)、可以绑定多个条件(ReentrantLock可以精确唤醒线程,不像synchronized只能随机唤醒或者全部唤醒)、轮询锁(通过多次tryLock()获得多个锁,如果不能同时获得,就全部释放,按固定时间+随机时间轮询再次请求)。ReentrantLock的几个重要方法:lock()、lockInterruptibly()、tryLock()、tryLock(long time, TimeUnit unit)、unlock()。

2、非阻塞同步

基于冲突检测的乐观策略,先进行操作,如果没有其他线程争用共享数据就操作成功,否则再采取补偿措施(最常见的补偿措施就是不断重试),这种方式不需要把线程挂起。

CAS(compare and swap)比较并交换。CAS指令需要3个操作数:内存位置、旧预期值、新值。缺点是存在ABA问题

3、无同步方案

如果一个变量要被多线程访问,可以声明为volatile;如果一个变量要被线程独享就使用ThreadLocal存储。把共享数据的可见范围限制在同一个线程之内。最经典的应用实例——“一个请求对应一个服务器线程”。

  • 锁优化

锁优化是针对互斥同步来进行。由于互斥同步阻塞和唤醒都需要转到内核态,给操作系统并发性能带来了很大压力。

1、自旋锁、自适应自旋

让后面请求锁的线程不阻塞,而是“稍等一下”。jdk1.6之前自旋次数默认10次,并且可以通过参数修改。1.6之后引入了自适应自旋锁,自旋的时间或次数由前一次在同一个锁上的自旋时间及锁的拥有者状态决定。如果在同一个锁对象上,自旋等待刚刚获得过锁,并且持有锁的线程正在运行中,那么虚拟机会认为这次自旋也很有可能成功,进而允许自旋更长时间。如果对于某个锁对象很少自旋获得成功过,以后则可能会省略自旋过程。

2、锁消除

虚拟机即时编译器在运行时,检测到某些不存在共享竞争的数据加了锁则进行消除。

比如StringBuffer的append()方法中都有一个同步块,若jvm发现StringBuffer的对象的所有引用都不会逃逸(逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,可能会通过参数传递被外部方法所引用,称为方法逃逸;赋值给类变量或可以在其他线程访问到的实例变量称为线程逃逸)到方法体之外,则会消除同步块。

3、锁粗化

我们在写代码时总是推荐同步块的作用范围尽量小,但如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁是出现在循环体中的,则不如直接将锁粗化到整个操作序列的外部。比如StringBuffer连续的append操作连续的加锁解锁,不如把锁粗化到第一个append之前和最后一个append之后。

4、轻量级锁

轻量级锁并不是用来替代重量级锁的,它能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”。轻量级锁的执行过程是:在代码进入同步块时,如果同步对象没有被锁定(锁标志位为“01”),虚拟机将锁对象的Mark Word(对象头存储的哈希码、GC年龄等)拷贝到当前线程的栈帧,并使用CAS操作尝试将对象头的Mark Word更新为指向副本的指针。若更新成功,则这个线程拥有了该对象的锁,并且对象的锁标志位转变为“00”;若更新失败,首先检查对象的Mark Word是否指向当前线程的栈帧,若指向则说明当前线程已经拥有了对象锁,直接进入同步块执行,否则说明锁对象已经被其他线程抢占了,那轻量级锁不再有效,要膨胀为重量级锁,锁标志位转为“10”。解锁过程也是通过CAS操作进行。

5、偏向锁

如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步互斥,那偏向锁就是在无竞争的情况下把整个同步都消除,连CAS操作都不做了。它的原理是:当线程请求的锁对象后,将Mark Word中锁对象的状态标志位改为“01”,即偏向模式。然后使用CAS操作将线程的ID记录在Mark Word中,以后该线程可以直接进入同步块,连CAS都不用做。但是一旦有第二条线程竞争锁,则偏向模式立即结束,膨胀为轻量级锁。

6、锁的膨胀过程

当线程A访问同步代码块时,此时同步对象是无锁状态(MarkWord中锁标志位为01、线程ID为空),所以将对象头设置为偏向锁状态,使用CAS将线程ID设置为当前线程ID。当线程B访问同步代码块时,发现同步对象是偏向锁状态,检查持有锁的线程A是否存活。若A已经挂了则将同步对象设置为无锁状态,然后偏向线程B;否则A持有的锁膨胀为轻量级锁(锁标志位转为00、MarkWord),B自旋一段时间。当B自旋结束或者又来一个线程C,A还没释放轻量级锁就膨胀为重量级锁(锁标志位转为10,指针指向monitor对象)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章