Java进阶

  • JVM简介
  • JVM内存结构
  • 程序计数器
  • 虚拟机栈
  • 一些思考
  • 栈内存溢出问题
  • 线程运行诊断方法
  • 本地方法栈
  • 堆内存溢出问题
  • 方法区
  • 内存溢出
  • 运行时常量池
  • StringTable
  • 直接内存
  • JVM垃圾回收
  • 垃圾回收判断方法
  • 五种引用类型
  • 垃圾回收算法
  • 分代垃圾回收机制
  • 新生代
  • 老年代
  • 垃圾回收器
  • 编译期处理
  • Java内存模型
  • 原子性
  • 可见性
  • 有序性
  • CAS和原子类
  • 乐观锁和悲观锁
  • synchronized优化
  • 轻量级锁
  • 重量级锁
  • 偏向锁
  • 其他优化


JVM简介

Java Virtual Machine - Java二进制字节码文件的运行环境

JVM是一种规范,可以更具这样的规范开发属于自己的JVM。

Java程序执行原理:
Java源代码 --> 二进制字节码文件(其中包含JVM指令)
解释器将JVM指令翻译为对应机器的机器码,最后交由CPU执行。

好处:

  • 跨平台的基础
  • 自动内存管理机制,垃圾回收功能(减少内存泄漏问题)
  • 数组下标越界检查
  • 多态性

JVM内存结构

程序计数器

Program Counter Register 程序计数(寄存)器

  • 作用
    物理上通过寄存器来实现,记住下一条JVM指令的执行地址。
    指令执行时,会将下一条指令的地址放到程序计数器中,解释器访问程序计数器得到下一条指令并进行解释与执行。
  • 特点
  • 线程私有:每个线程都有属于自己的程序计数器
  • 不会内存溢出

虚拟机栈

线程运行时内存空间。每个线程有每个线程的虚拟机栈。

栈中的数据为栈帧(Frame),一个栈帧实际上就是每个方法运行时需要的内存。

调用方法时,会分配栈帧存储方法各种内容,然后压入虚拟机栈中。

每个线程只能有一个活动栈帧,对应正在执行的方法

一些思考

  • 垃圾回收不会处理栈内存
  • 栈内存可以在运行时指定,默认是1024KB,windows会和虚拟内存大小有关
  • 多线程情况下
  • 如果方法内局部变量没有逃离方法作用范围,即外部不会以任何方式访问到,则线程安全
  • 如果外部能够以某种方式访问到,则不是线程安全的

栈内存溢出问题

Error:StackOverFlow

  • 栈帧过多:递归等
  • 栈帧过大:很少见

线程运行诊断方法

  1. cpu占用过高
  • top命令查看进程
  • ps命令+grep管道定位线程
  • jstack+进程id找到有问题的线程
  1. 程序运行很久无结果:可能发生死锁
  • jstack+进程id查看死锁情况

本地方法栈

Native Method Stack

本地方法就是指引擎通过其他语言实现的方法。

JVM可以通过调用本地方法接口调用一些本地方法,由本地方法栈承载调用的本地方法。

通过new关键字创建的对象都会使用堆内存。

  • 线程共享,堆中的对象都要考虑线程安全问题
  • 有垃圾回收机制进行管理

堆内存溢出问题

Error:OutOfMemoryError:Java heap space

工具:

  • jps:查看当前系统中java进程
  • jmap:查看堆内存占用情况
  • jconsole:图形界面,多功能检测工具,可以连续监测

实际案例:当执行多次垃圾回收之后,内存占用依然很高

  • jvisualvm:更强大的图形化监视工具

方法区

Method Area

所有JVM线程共享,存储和类的结构相关的各种信息。在JVM启动时启动,逻辑上是堆的一部分,但具体实现不同,定义位置也不同。

方法区一般包含常量池、类和类加载器等。

Oracle Hotspot方法区实现说明:

JDK1.6使用PermGen永久代实现,是堆的一部分,由JVM进行管理

JDK1.8使用Metaspace元空间实现,是本地内存的一部分,由元空间自己管理

内存溢出

可能出现在spring,mybatis等框架的使用中。

这些框架使用cglib自动生成一些代理类,mapper类等。可能会使方法区内存溢出。

运行时常量池

  • 常量池:在.class文件中,是一张常量表,虚拟机指令查找要执行的各种信息:类名,方法名等
  • 运行时常量池:当一个类被加载时,它的常量池信息就被放入运行时常量池,并会把其中的符号地址变为真实地址
StringTable

底层采用类似哈希表的形式实现,由于是避免重复的,因此能够大量减少堆内存的占用,适用于大量字符串操作时。

JDK1.6是放在方法区中的,即PermGen永久代。

JDK1.8更改为放在堆内存中。

几个特性:

  • 直接赋值的字符串常量是存在于StringTable中的,且是唯一存在的。
  • 通过字符串对象拼接操作生成的字符串,JVM会使用StringBuilder对象进行处理,这是相当于new了一个新的String对象,是存在于堆内存中的,不会放入串池中。

编译期间可能会发生变化,所以不能作为字符串常量进行处理

  • 通过字符串常量拼接操作生成的字符串,JVM会直接加载StringTable中存在的串,存在于StringTable中。

这种实际上是javac在编译期间的优化,编译期间已经确定

可以通过intern方法,主动放入某个字符串对象。不管成功与否,都会返回串池中的对象。

StringTable调优:调大Map桶的个数,减少碰撞次数

直接内存

不属于JVM内存管理,而是系统内存管理的一部分。

常用语NIO操作时,用于数据缓冲区,分配回收成本较高,读写性能好。

JDK内部使用Unsafe类对直接内存进行操作,我们可以使用ByteBuffer可以分配直接内存。

JVM垃圾回收

垃圾回收判断方法

  1. 引用计数法:判断一个对象被其他对象引用的次数,如果引用数为0,则判断可以回收。

无法解决循环引用的问题,造成内存泄漏

  1. 可达性分析方法:
  1. GC Root根对象:一些一定不被回收的对象
    System Class Native Stack Busy Monitor(Lock加锁的对象) Thread
  2. 判断方法:扫描堆中的对象,看是否能够沿着GC Root对象为起点的引用链找到该对象,如果找不到,则表示可以回收。

五种引用类型

  1. 强引用:
  • 只有所有根对象都不通过强引用引用一个对象时,才能回收
  1. 软引用 SoftReference:可能需要两次GC
  • 垃圾回收后,发现内存不足时,触发Full GC,会发生回收
  1. 弱引用 WeakReference
  • 只要垃圾回收,就会回收

软、弱引用可以和相应的引用队列配合使用,因为软弱引用本身就是一种对象,会占用一定内存空间,如果要回收这两种对象,就需要引用队列辅助进行。

  1. 虚引用
  • 虚引用的引用对象回收时,虚引用会进入引用队列,等待处理
  1. 终结器引用(对象finalize方法):需要两次GC
  • 终结器引用的对象在回收时不会先回收,要先讲终结器引用入队,等待处理

虚引用和终结器引用必须和引用队列配合使用。

垃圾回收算法

  1. 标记清除
  1. 先对不可达的对象进行标记
  2. 对标记过的对象进行清除
  • 优点:速度快
  • 缺点:容易产生内存碎片
  1. 标记整理
  1. 对不可达对象进行标记
  2. 在清理过程中讲可达对象进行整理,整理到连续空间中
  • 优点:不产生内存碎片
  • 缺点:需要修改对象在内存中的地址,速度较慢
  1. 复制算法
  1. 将内存区划分为大小相等的两块区域:From,To
  2. 对From区中不可达对象进行标记
  3. 将存活对象复制到To区,并进行整理
  4. 清空From区,并将From和To区进行交换
  • 优点:不产生内存碎片
  • 缺点:占用双倍空间

分代垃圾回收机制

对不同需求的对象分别存放到新生代和老年代中,分别选取不同的回收算法,回收周期进行处理,尽量达到最优性能。

垃圾回收过程会触发stop the world,暂停其他所有用户线程

新生代

回收较频繁。

  1. 伊甸园eden:新对象默认放置位置
    当满之后,执行Minor GC,执行复制算法,到To区,并使存活年龄加1。
  2. 幸存区:From&To
    存放伊甸园中一次复制算法垃圾回收幸存的对象,记录存活年龄。

老年代

存放长时间生存的对象,执行频率低。

新生代对象寿命超过一定阈值(最大为15,占4bit)时,将晋升到老年代。

老年代空间不足时会触发Full GC,标记整理,时间更长。

对于大对象,如果超过eden的大小,会直接放到老年代中。

垃圾回收器

  1. 串行
  • 单线程
  • 适合堆内存较小时,比如个人电脑
  1. 吞吐量优先
  • 多线程:并行,多个垃圾回收线程并行处理
  • 适合对内存较大,需要多核CPU
  • 单位时间内stop the world(STW)时间尽可能短
  1. 响应时间优先
  • 多线程:并发,可以同时运行用户线程
  • 适合对内存较大,需要多核CPU
  • 使得单次stop the world(STW)尽可能短

编译期处理

语法糖:java编译器在编译过程中,自动生成和转换一些代码,减轻程序员负担。

  1. 默认构造器:默认生成类的无参构造器
  2. 自动拆装箱:基本类型及包装类之间的相互转化
  3. 泛型集合取值:
    泛型擦除:编译期间会对泛型类型进行忽略,以Object进行操作
    读取时会做一个强制类型转换。
  4. 可变参数
  5. for…each
  6. 数组赋初始值的简化写法
  7. switch用于字符串和枚举类型:转化成两个switch,一个用hashcode和equals找结果,一个是最后的switch

Java内存模型

Java Memory Model JMM

JMM定义了一套多线程读写共享数据时,对数据可见性,有序性和原子性的规则和保障。

原子性

synchronized关键字,通过monitor管理。

既能保证原子性,也能保证可见性,但是属于重量级操作,性能相对更低。

可见性

volatile关键字:避免线程从自己的高速缓存中读取变量。

适用于一个写线程,多个读线程的情况。

有序性

JIT指令重排带来的问题。

volatile关键字修饰,能够避免指令重排。

CAS和原子类

compareAndSwap,是一种乐观锁的思想。

通过对比读到的旧共享变量值和当前的共享变量值,判断是否进行下一步操作或重试。

需要和volatile结合使用,可以实现无锁并发,适用于竞争不激烈,多核CPU的场景下。

  • 没有使用synchronized,线程不会陷入阻塞,能够提升效率
  • 如果竞争激烈,会不断重试,反而会影响效率

底层通过Unsafe类直接调用操作系统底层的CAS指令。

乐观锁和悲观锁

CAS和synchronized

CAS为最乐观的估计,以重试来解决。

synchronized是最悲观的估计,排他性很强。

synchronized优化

轻量级锁

假设,如果一个对象由多线程访问,但是访问交错进行,那么可以用轻量级锁进行优化。

当是同一线程的不同代码块反复进入时,可以不用反复加锁,可重入。

但如果不同线程进入时,会进行锁升级,将轻量级锁设置为重量级锁,进入阻塞状态。

重量级锁

自旋优化:在竞争时,不直接进入阻塞状态,而是通过自旋反复访问,从而减少因阻塞造成的资源浪费。

自旋超过一定次数,会进入阻塞。

偏向锁

偏向锁会在第一次,用CAS将线程ID设置到对象的头,之后如果发现是自己,就不用CAS重新检查。

  • 撤销偏向需要将锁升级为轻量级锁,这个过程会出发STW
  • 访问对象的hashCode会撤销偏向锁
  • 撤销偏向和重偏向是批量进行的,以类为单位
  • 如果撤销偏向达到一定次数阈值,那么会使整个类所有对象都变为不可偏向

其他优化

  1. 减少上锁时间
  2. 减少锁的粒度:将一个锁拆分为多个锁来提高并发度(concurrentHashMap对每个链表加锁)
  3. 锁粗化:多次进入同步块换成同步块中多次循环
  4. 锁消除:JVM会进行代码逃逸分析,如果某个对象没有外界使用,会消除其身上的锁
  5. 读写分离