Java 内存区域与内存溢出异常
运行时数据区域
程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
Java虚拟机栈
- 栈帧
- 每个方法被执行的时候,Java 虚拟就都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
- 局部变量表
- 局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址)。
- 在《 Java 虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果 Java 虚拟机栈容量可以动态扩展[2],当栈扩展时无法申请到足够的内存会抛出 OutOfMemoryError 异常。
本地方法栈
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
Java 堆
Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。
方法区
Java 方法区是被所有线程共享的一块内存区域。存储虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。
如果方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError 异常。
运行时常量池
运行时常量池 是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
直接内存
HotSpot 虚拟机对象探秘
对象创建
- 分配内存
- 指针碰撞
- 空闲列表
- 线程安全
- 加锁同步
- 本地线程分配缓存
对象的内存布局
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
- 对象头 (Header)
- 是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
- 另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。
- 实例数据(Instance Data)
- 对齐填充(Padding)
对象的访问定位
主流的访问方式主要有使用句柄和直接指针两种:
- 如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。
- 如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。
这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
垃圾收集器与内存分配策略
对象已死
引用计数法
可达性分析法
可达性分析法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
垃圾收集算法
标记 - 清除算法
标记 - 复制算法
标记 - 整理算法
分代收集算法
根据分代的不同选择不同的垃圾收集算法
垃圾收集器
Serial 收集器
- 最基本,发展最悠久的;
- 单线程工作的收集器;(它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。)
- 简单高效
ParNew 收集器
ParNew收集器实质上是Serial收集器的多线程并行版本
Parallel Scavenge 收集器
- 基于标记 - 复制算法
- 多线程收集器
- 可控制的吞吐量
Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。
Parallel Old 收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于标记 - 真理算法实现。
CMS 收集器 (Concurrent Mark Sweep)
工作过程:
- 初始标记
- 并发标记
- 重新标记
- 并发标记
明显缺点:
- 对处理器资源非常敏感。
- 无法处理浮动垃圾,有可能出现 “ Concurrent Model Failuer” 失败进而道义另一次完全的 ”Stop The World“ 的 Full GC 的产生。
- 大量空间碎片。CMS 是一款基于 ”标记 - 清除“ 算法实现的收集器。
Garbage First 收集器
G1采用了分区(Region)的思路,将整个堆空间分成若干个大小相等的内存区域,每次分配对象空间将逐段地使用内存。在每个分区内部又被分成了若干个大小为512 Byte卡片(Card),标识堆内存最小可用粒度所有分区的卡片将会记录在全局卡片表(Global Card Table)中,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过记录卡片来查找该引用对象。
优势:
- 并行与并发
- 分代收集
- 空间整合
- 可预测的停顿
工作过程:
- 初始标记
- 并发标记
- 最终标记
- 筛选挥手
内存分配策略
- 优先分配 eden
- 大对象直接分配到老年代
- 长期存活的对象分配到老年代
- 空间分配担保
- 动态对象年龄判断
逃逸分析与栈上分配
逃逸分析:就是分析出对象的作用域。当一个对象在方法体内声明后,该对象的引用被其他外部所引用时该对象就发生了逃逸,反之就会在栈帧中为对象分配内存空间。
栈上分配:主要是指在Java程序的执行过程中,在方法体中声明的变量以及创建的对象,将直接从该线程所使用的栈中分配空间。 一般而言,创建对象都是从堆中来分配的,这里是指在栈上来分配空间给新创建的对象。
虚拟机性能监控、故障处理工具
jps
作用:用于查看虚拟机运行了那些进程,并输出这些进程LVMID,即进程id,它是使用最频繁的一个命令,因为其它工具需要依赖jps。首先需要jps输出jvm正在运行的的进程id;然后其它工具根据进程id进行监控对应的进程运行情况。
jmap
作用:内存映像工具,可用生成堆内存转存快照dump,它还可以查询finalize执行队列,java堆和永久代的详细信息,例如:空间利用率,当前用的是那种收集器等。
jstat
作用:用于jvm虚拟机统计信息监控的,主要用于监控jvm内存使用率。
jinfo
作用:实时查看java配置信息工具,它也可以实时调整虚拟机各项配置参数的值。
jConsole
VisualVM
类文件结构
平台无关性
Java虚拟机不和包括Java在内的任何语言绑定 它只与“Class文件”这种特定的二进制文件格式所关联 Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息 基于安全方面的考虑 Java虚拟机规范要求在Class文件中使用许多强制性的语法和结构化约束 但任一门功能性语言都可以表示为一个能被Java虚拟机所接受的有效的Class文件。
Class 类文件的结构
Class文件是一组以8位字节为基础单位的二进制流 各个数据项目严格按照顺序紧凑地排列在Class文件之中 中间没有添加任何分隔符 这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据 没有空隙存在 当遇到需要占用8位字节以上空间的数据项时 则会按照高位在前的方式分割成若干个8位字节进行存储
魔数与 Class 文件的版本
每个Class文件的头4个字节称为魔数(Magic Number) 它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。
常量池
紧接着主次版本号之后的是常量池入口 常量池可以理解为Class文件之中的资源仓库 它是Class文件结构中与其他项目关联最多的数据类型 也是占用Class文件空间最大的数据项目之一 同时它还是在Class文件中第一个出现的表类型数据项目。
访问标志
在常量池结束之后 紧接着的两个字节代表访问标志(access_flags) 这个标志用于识别一些类或者接口层次的访问信息 包括:这个Class是类还是接口 是否定义为public类型 是否定义为abstract类型 如果是类的话 是否被声明为final等。
类索引、父类索引与接口索引集合
类索引(this_class)和父类索引(super_class)都是一个u2类型的数据 而接口索引集合(interfaces)是一组u2类型的数据的集合 Class文件中由这三项数据来确定这个类的继承关系
字段表集合
字段表(field_info)用于描述接口或者类中声明的变量 字段(field)包括类级变量以及实例级变量 但不包括在方法内部声明的局部变量
方法表集合
Class文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式 方法表的结构如同字段表一样 依次包括了访问标志(access_flags) 名称索引(name_index) 描述符索引(descriptor_index) 属性表集合(attributes)几项
属性表集合
在Class文件 字段表 方法表都可以携带自己的属性表集合 以用于描述某些场景专有的信息
- Code属性
Java程序方法体中的代码经过Javac编译器处理后 最终变为字节码指令存储在Code属性
内 - Exceptions属性
Exceptions属性的作用是列举出方法中可能抛出的受查异常(Checked Excepitons) 也就是方法描述时在throws关键字后面列举的异常 - LineNumberTable属性
LineNumberTable属性用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系 - LocalVariableTable属性
LocalVariableTable属性用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之
间的关系 - SourceFile属性
SourceFile属性用于记录生成这个Class文件的源码文件名称 - ConstantValue属性
ConstantValue属性的作用是通知虚拟机自动为静态变量赋值 只有被static关键字修饰的
变量(类变量)才可以使用这项属性 - InnerClasses属性
InnerClasses属性用于记录内部类与宿主类之间的关联 如果一个类中定义了内部类 那编译器将会为它以及它所包含的内部类生成InnerClasses属性 - Deprecated及Synthetic属性
Deprecated和Synthetic两个属性都属于标志类型的布尔属性 只存在有和没有的区别 没有属性值的概念
Deprecated属性用于表示某个类 字段或者方法 已经被程序作者定为不再推荐使用 它可以通过在代码中使用@deprecated注释进行设置
Synthetic属性代表此字段或者方法并不是由Java源码直接产生的 而是由编译器自行添加的 - StackMapTable属性
这个属性会在虚拟机类加载的字节码验证阶段被新类型检查验证器(Type Checker)使用 目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器 - Signature属性
任何类 接口 初始化方法或成员的泛型签名如果包含了类型变量(Type Variables)或参数化类型(Parameterized Types) 则Signature属性会为它记录泛型签名信息 - BootstrapMethods属性
这个属性用于保存invokedynamic指令引用的引导方法限定符
字节码指令简介
Java虚拟机的指令由一个字节长度的 代表着某种特定操作含义的数字(称为操作码 Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数 Operands)而构成 由于Java虚拟机采用面向操作数栈而不是寄存器的架构 所以大多数的指令都不包含操作数 只有一个操作码
字节码与数据类型
在Java虚拟机的指令集中 大多数的指令都包含了其操作所对应的数据类型信息 例如 iload指令用于从局部变量表中加载int型的数据到操作数栈中 而fload指令加载的则是float类型的数据 这两条指令的操作在虚拟机内部可能会是由同一段代码来实现的 但在Class文件中它们必须拥有各自独立的操作码
加载和存储指令
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输
运算指令
运算或算术指令用于对两个操作数栈上的值进行某种特定运算 并把结果重新存入到操作栈顶
类型转换指令
类型转换指令可以将两种不同的数值类型进行相互转换 这些转换操作一般用于实现用户代码中的显式类型转换操作 或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题
对象创建与访问指令
虽然类实例和数组都是对象 但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令 对象创建后 就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素
操作数栈管理指令
如同操作一个普通数据结构中的堆栈那样 Java虚拟机提供了一些用于直接操作操作数栈的指令
控制转移指令
控制转移指令可以让Java虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序 从概念模型上理解 可以认为控制转移指令就是在有条件或无条件地修改PC寄存器的值
方法调用和返回指令
invokevirtual指令用于调用对象的实例方法 根据对象的实际类型进行分派(虚方法分派) 这也是Java语言中最常见的方法分派方式
invokeinterface指令用于调用接口方法 它会在运行时搜索一个实现了这个接口方法的对象 找出适合的方法进行调用
invokespecial指令用于调用一些需要特殊处理的实例方法 包括实例初始化方法 私有方法和父类方法
invokestatic指令用于调用类方法(static方法)
invokedynamic指令用于在运行时动态解析出调用点限定符所引用的方法 并执行该方法 前面4条调用指令的分派逻辑都固化在Java虚拟机内部 而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的
异常处理指令
在Java程序中显式抛出异常的操作(throw语句)都由athrow指令来实现 除了用throw语句显式抛出异常情况之外 Java虚拟机规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出 而在Java虚拟机中 处理异常(catch语句)不是由字节码指令来实现的(很久之前曾经使用jsr和ret指令来实现 现在已经不用了) 而是采用异常表来完成的
同步指令
Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步 这两种同步结构都是使用管程(Monitor)来支持的同步一段指令集序列通常是由Java语言中的synchronized语句块来表示的 Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义 正确实现synchronized关键字需要Javac编译器与Java虚拟机两者共同协作支持
公有设计和私有实现
Java虚拟机规范描绘了Java虚拟机应有的共同程序存储格式:Class文件格式以及字节码指令集 这些内容与硬件 操作系统及具体的Java虚拟机实现之间是完全独立的 虚拟机实现者可能更愿意把它们看做是程序在各种Java平台实现之间互相安全地交互的手段
虚拟机实现的方式主要有以下两种:
将输入的Java虚拟机代码在加载或执行时翻译成另外一种虚拟机的指令集
将输入的Java虚拟机代码在加载或执行时翻译成宿主机CPU的本地指令集(即JIT代码生
成技术)
虚拟机类加载机制
虚拟机把描述类的数据从 Class 文件加载到内存, 并对数据进行校验,解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型, 这就是虚拟机的类加载机制。
重点
JDK 提供的并发容器总结
JDK 提供的这些容器大部分在 java.util.concurrent
包中。
- ConcurrentHashMap: 线程安全的 HashMap
- CopyOnWriteArrayList: 线程安全的 List,在读多写少的场合性能非常好,远远好于 Vector.
- ConcurrentLinkedQueue: 高效的并发队列,使用链表实现。可以看做一个线程安全的 LinkedList,这是一个非阻塞队列。
- BlockingQueue: 这是一个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。
- ConcurrentSkipListMap: 跳表的实现。这是一个 Map,使用跳表的数据结构进行快速查找。
ConcurrentHashMap 和 HashMap 的区别
ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。
- 底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
- 实现线程安全的方式(重要): ① 在 JDK1.7 的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 对 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;② Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
何谓悲观锁与乐观锁
乐观锁对应于生活中乐观的人总是想着事情往好的方向发展,悲观锁对应于生活中悲观的人总是想着事情往坏的方向发展。这两种人各有优缺点,不能不以场景而定说一种人好于另外一种人。
悲观锁
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized
和ReentrantLock
等独占锁就是悲观锁思想的实现。
乐观锁
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic
包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
两种锁的使用场景
从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。
乐观锁常见的两种实现方式
乐观锁一般会使用版本号机制或CAS算法实现。
(………………………………………………)