Java基础、集合、JVM
基础
1. JVM、JDK和JRE
JVM(Java虚拟机)是运行Java字节码的虚拟机。JVM针对不同系统有不同的实现。
JDK是Java Development Kit的缩写,它是功能齐全的 Java SDK。它拥有 JRE 所拥有的一切,还有编译器(javac)和工具(如 javadoc 和 jdb)。它能够创建和编译程序。
JRE是java运行时环境,它是运行已编译 Java 程序所需的所有内容的集合,包括 Java 虚拟机(JVM),Java 类库,java 命令和其他的一些基础构件。但是,它不能用于创建新程序。
2. 方法重载、方法重写
- 重载
发生在同一个类中(或者父类和子类之间),方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以不同。简单理解:重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理。
- 重写
重写发生在运行期,是子类对父类的允许访问的方法的实现过程进行重新编写。重写遵循“两同两小一大”:
“两同"即方法名相同,形参列表相同;
“两小”指的是子类方法返回值类型应比父类方法返回值类型更小或相等,子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等;
“一大”指的是子类方法的访问权限应比父类方法的访问权限更大或相等。
注意:重写的返回值类型需要额外说明的是,如果方法的返回类型是 void 和基本数据类型,则返回值重写时不可修改。但是如果方法的返回值是引用类型,重写时是可以返回该引用类型的子类的。
3. 包装类型的缓存机制
Java 基本数据类型的包装类型的大部分都用到了缓存机制来提升性能。超过范围则会重新创建对象。
Byte
,Short
,Integer
,Long
这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character
创建了数值在 [0,127] 范围的缓存数据,Boolean
直接返回 True
or False
。
由于有该缓存机制,阿里巴巴Java开发手册强制要求:所有整型包装类对象之间的值比较,全部使用equals方法比较。
4. 重写equals()方法时,为什么要同时重写hashcode()方法。
hashcode()
方法和equals()
方法都用于比较两个对象是否相等。
当你把对象加入
HashSet
时,HashSet
会先计算对象的hashCode
值来判断对象加入的位置,同时也会与其他已经加入的对象的hashCode
值作比较,如果没有相符的hashCode
,HashSet
会假设对象没有重复出现。但是如果发现有相同hashCode
值的对象,这时会调用equals()
方法来检查hashCode
相等的对象是否真的相同。如果两者相同,HashSet
就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样我们就大大减少了equals
的次数,相应就大大提高了执行速度。
因为会出现哈希碰撞问题,所以两个对象hashcode值相等,但可能对象不相等。总结有如下:
- 如果两个对象的
hashCode
值相等,那这两个对象不一定相等(哈希碰撞)。 - 如果两个对象的
hashCode
值相等并且equals()
方法也返回true
,我们才认为这两个对象相等。 - 如果两个对象的
hashCode
值不相等,我们就可以直接认为这两个对象不相等。
因此,如果重写 equals()
时没有重写 hashCode()
方法的话就可能会导致 equals
方法判断是相等的两个对象,hashCode
值却不相等。
5. String、StringBuilder和StringBuffer区别
- 可变性
String
不可变
String为什么不可变?(Java String类的设计思想)
- 保存字符串的数组被
final
修饰且为私有的,并且String
类没有提供/暴露修改这个字符串的方法。String
类被final
修饰导致其不能被继承,进而避免了子类破坏String
不可变。注意:
final
关键字修饰的数组保存字符串并不是String
不可变的根本原因。因为这个数组保存的字符串是可变的(final
修饰引用类型变量的情况)。被
final
关键字修饰的类不能被继承,修饰的方法不能被重写,修饰的变量是基本数据类型则值不能改变,修饰的变量是引用类型则不能再指向其他对象。
Java 9 为何要将
String
的底层实现由char[]
改成了byte[]
?在 Java 9 之后,
String
、StringBuilder
与StringBuffer
的实现改用byte
数组存储字符串。新版的 String 其实支持两个编码方案: Latin-1 和 UTF-16。如果字符串中包含的汉字没有超过 Latin-1 可表示范围内的字符,那就会使用 Latin-1 作为编码方案。Latin-1 编码方案下,
byte
占一个字节(8 位),char
占用 2 个字节(16),byte
相较char
节省一半的内存空间。JDK 官方就说了绝大部分字符串对象只包含 Latin-1 可表示的字符。如果字符串中包含的汉字超过 Latin-1 可表示范围内的字符,
byte
和char
所占用的空间是一样的。
StringBuilder
与 StringBuffer
都继承自 AbstractStringBuilder
类,在 AbstractStringBuilder
中也是使用字符数组保存字符串,不过没有使用 final
和 private
关键字修饰,最关键的是这个 AbstractStringBuilder
类还提供了很多修改字符串的方法比如 append
方法。
- 线程安全性
String
中的对象是不可变的,也就可以理解为常量,线程安全。
StringBuffer
对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder
并没有对方法进行加同步锁,所以是非线程安全的。
- 性能
每次对String进行修改,都会生成新的String对象,性能差。
相同情况下使用 StringBuilder
相比使用 StringBuffer
仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。
总结:
1.操作少量数据:适合
String
2.单线程操作字符串缓冲区下操作大量数据: 适用
StringBuilder
3.多线程操作字符串缓冲区下操作大量数据: 适用
StringBuffer
集合
Java的集合主要由两大类接口派生而来,如下图所示:
List
ArrayList和LinkedList的区别
-
是否保证线程安全:ArrayList和LinkedList都是不保证线程安全的。
-
底层数据结构: ArrayList底层使用Object数组,LinkedList底层使用双向链表。
-
插入和删除是否受元素位置影响
- ArrayList底层采用数组结构实现,所以插入和删除元素的时间复杂度受元素位置的影响。
- LinkedList底层采用双向链表实现,头尾插入元素的时间复杂度为O(1),但是中间插入和删除元素的时间复杂度是O(n),因为需要先移动到指定位置。
-
是否支持快速随机访问: ArrayList支持快速随机访问,随机访问即通过元素序号快速获取元素位置。
-
内存空间占用: LinkedList会更加消耗空间,因为每个节点需要存储前驱和后继节点信息。
ArrayList扩容机制分析
以无参数构造方法创建 ArrayList
时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为 10。
下面是扩容的核心代码:
/**
* 要分配的最大数组大小
*/
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
/**
* ArrayList扩容的核心方法。
*/
private void grow(int minCapacity) {
// oldCapacity为旧容量,newCapacity为新容量
int oldCapacity = elementData.length;
//将oldCapacity 右移一位,其效果相当于oldCapacity /2,
//我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍,
int newCapacity = oldCapacity + (oldCapacity >> 1);
//然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量,
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
// 如果新容量大于 MAX_ARRAY_SIZE,进入(执行) `hugeCapacity()` 方法来比较 minCapacity 和 MAX_ARRAY_SIZE,
//如果minCapacity大于最大容量,则新容量则为`Integer.MAX_VALUE`,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 `Integer.MAX_VALUE - 8`。
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
int newCapacity = oldCapacity + (oldCapacity » 1),所以 ArrayList 每次扩容之后容量都会变为原来的 1.5 倍左右。
为什么会设置MAX_ARRAY_SIZE这样一个限制,是一位某些JVM有一些实现限制,可能会导致OutOfMemoryError(“Requested array size exceeds VM limit”)。
Set
HashSet、LinkedHashSet和TreeSet的区别
HashSet
、LinkedHashSet
和TreeSet
都是Set
接口的实现类,都能保证元素唯一,并且都不是线程安全的。HashSet
、LinkedHashSet
和TreeSet
的主要区别在于底层数据结构不同。HashSet
的底层数据结构是哈希表(基于HashMap
实现)。LinkedHashSet
的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet
底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。- 底层数据结构不同又导致这三者的应用场景不同。
HashSet
用于不需要保证元素插入和取出顺序的场景,LinkedHashSet
用于保证元素的插入和取出顺序满足 FIFO 的场景,TreeSet
用于支持对元素自定义排序规则的场景。
Queue
Queue和Deque的区别
Queue
是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 先进先出(FIFO) 规则。Queue
扩展了 Collection
的接口,根据 因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。
Queue接口 | 抛出异常 | 返回特殊值 |
---|---|---|
插入队尾 | add(E e) | offer(E e) |
删除队首 | remove() | poll() |
查询队首元素 | element() | peek() |
Deque
是双端队列,在队列的两端均可以插入或删除元素。
ArrayDeque和LinkedList的区别
ArrayDeque
和 LinkedList
都实现了 Deque
接口,两者都具有队列的功能,二者的区别是:
ArrayDeque
是基于可变长的数组和双指针来实现,而LinkedList
则通过链表来实现。ArrayDeque
不支持存储NULL
数据,但LinkedList
支持。ArrayDeque
是在 JDK1.6 才被引入的,而LinkedList
早在 JDK1.2 时就已经存在。ArrayDeque
插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然LinkedList
不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。
从性能的角度上,选用 ArrayDeque
来实现队列要比 LinkedList
更好。此外,ArrayDeque
也可以用于实现栈。
Map
HashMap和Hashtable的区别
- 线程是否安全:
HashMap
是非线程安全的,Hashtable
是线程安全的,因为Hashtable
内部的方法基本都经过synchronized
修饰。(如果你要保证线程安全的话就使用ConcurrentHashMap
吧!) - **效率:**因为线程安全的问题,
HashMap
要比Hashtable
效率高一点。另外,Hashtable
基本被淘汰,不要在代码中使用它 - 对null key和null value的支持:
HashMap
可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出NullPointerException
。 - **初始容量大小和扩容大小不同:**① 创建时如果不指定容量初始值,
Hashtable
默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap
默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么Hashtable
会直接使用你给定的大小,而HashMap
会将其扩充为 2 的幂次方大小。
扩充为2的幂次方大小原因是?
hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方,其中length是数组的长度。
- **底层数据结构:**JDK1.8 以后的
HashMap
在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)。Hashtable
没有这样的机制。
HashMap底层实现
- JDK1.8之前
JDK1.8 之前 HashMap
底层是 数组和链表 结合在一起使用也就是 链表散列。HashMap 通过 key 的 hashcode
经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash
判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
所谓扰动函数指的就是 HashMap 的 hash
方法。使用 hash
方法也就是扰动函数是为了防止一些实现比较差的 hashCode()
方法 换句话说使用扰动函数之后可以减少碰撞。JDK1.8HashMap的hash方法源码如下:
static final int hash(Object key) {
int h;
// key.hashCode():返回散列值也就是hashcode
// ^ :按位异或
// >>>:无符号右移,忽略符号位,空位都以0补齐
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
- JDK1.8之后
JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
红黑树有这样的性质:一棵大小为N的红黑树高度不会超过2lgN, 因此红黑树的插入、查找时间复杂度为O(lgN)。
HashMap多线程操作导致死循环
主要原因在于并发下的 Rehash 会造成元素之间会形成一个循环链表。不过,jdk 1.8 后解决了这个问题,但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在其他问题比如数据丢失。并发环境下推荐使用 ConcurrentHashMap 。
HashMap常见遍历方式及性能比较
HashMap常见有7种遍历方式,如下:
- 使用迭代器(Iterator)EntrySet 的方式进行遍历;
- 使用迭代器(Iterator)KeySet 的方式进行遍历;
- 使用 For Each EntrySet 的方式进行遍历;
- 使用 For Each KeySet 的方式进行遍历;
- 使用 Lambda 表达式的方式进行遍历;
- 使用 Streams API 单线程的方式进行遍历;
- 使用 Streams API 多线程的方式进行遍历。
// 1. 迭代器EntrySet
Iterator<Map.Entry<Integer, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Integer, String> entry = iterator.next();
System.out.println(entry.getKey());
System.out.println(entry.getValue());
}
// 2. 迭代器KeySet
Iterator<Integer> keys = map.keySet().iterator();
while (keys.hasNext()) {
Integer key = keys.next();
System.out.println(key);
System.out.println(map.get(key));
}
// 3. forEach EntrySet
for (Map.Entry<Integer, String> entry : map.entrySet()) {
System.out.println(entry.getKey());
System.out.println(entry.getValue());
}
// 4. forEach KeySet
for (Integer key : map.keySet()) {
System.out.println(key);
System.out.println(map.get(key));
}
// 5.Lambda
map.forEach((key, value) -> {
System.out.println(key);
System.out.println(value);
});
// 6. Stream API 单线程
map.entrySet().stream().forEach((entry) -> {
System.out.println(entry.getKey());
System.out.println(entry.getValue());
});
// 7. Stream API 多线程
map.entrySet().parallelStream().forEach((entry) -> {
System.out.println(entry.getKey());
System.out.println(entry.getValue());
});
最终结果是 多线程的Stream API性能最好,不用考虑,EntrySet性能是最好的,所以应该多使用EntrySet的方式进行遍历。综合安全性方面,应该使用迭代器的方式来遍历HashMap。
当然也可以使用Lambda中的removeIf
来提前删除数据,或者使用Stream中的filter
过滤掉要删除的数据。这两种方式也是安全的。
// Lambda的removeIf方式提前删除数据
map.keySet().removeIf(key -> key == 1);
map.forEach((key, value) -> {
System.out.println(key);
});
// Stream APi中filter方式删除数据
map.entrySet().stream()
.filter(m -> 1 != m.getKey())
.forEach(System.out::println);
ConcurrentHashMap与Hashtable的区别
二者的主要区别在于实现线程安全的方式上不同。
- 底层数据结构: JDK1.7 的
ConcurrentHashMap
底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8
的结构一样,数组+链表/红黑二叉树。Hashtable
和 JDK1.8 之前的HashMap
的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的; - 实现线程安全的方式
- 在 JDK1.7 的时候,
ConcurrentHashMap
对整个桶数组进行了分割分段(Segment
,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 - 到了 JDK1.8 的时候,
ConcurrentHashMap
已经摒弃了Segment
的概念,而是直接用Node
数组+链表+红黑树的数据结构来实现,并发控制使用synchronized
和 CAS 来操作。(JDK1.6 以后synchronized
锁做了很多优化) 整个看起来就像是优化过且线程安全的HashMap
,虽然在 JDK1.8 中还能看到Segment
的数据结构,但是已经简化了属性,只是为了兼容旧版本; Hashtable
(同一把锁) :使用synchronized
来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
- 在 JDK1.7 的时候,
下面看看底层数据结构的对比图:
Hashtable:
JDK1.7的ConcurrentHashMap:
ConcurrentHashMap
是由Segment
数组结构和HashEntry
数组结构组成。Segment
数组中的每个元素包含一个HashEntry
数组,每个HashEntry
数组属于链表结构。
JDK1.8 的ConcurrentHashMap:
JDK1.8是 Node 数组 + 链表 / 红黑树。不过,Node 只能用于链表的情况,红黑树的情况需要使用
TreeNode
。当冲突链表达到一定长度时,链表会转换成红黑树。
TreeNode
是存储红黑树节点,被TreeBin
包装。TreeBin
通过root
属性维护红黑树的根结点,因为红黑树在旋转的时候,根结点可能会被它原来的子节点替换掉,在这个时间点,如果有其他线程要写这棵红黑树就会发生线程不安全问题,所以在ConcurrentHashMap
中TreeBin
通过waiter
属性维护当前使用这棵红黑树的线程,来防止其他线程的进入。
ConcurrentHashMap的底层原理
Java8的ConcurrentHashMap
取消了 Segment
分段锁,采用 Node + CAS + synchronized
来保证并发安全。数据结构跟 HashMap
1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。
Java 8 中,锁粒度更细,synchronized
只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
JVM
Java内存区域
运行时数据区域
Java虚拟机在执行Java程序时,会把他管理的内存划分为若干个不同的数据区域:
线程私有的:程序计数器、虚拟机栈、本地方法栈;线程共享的:堆、方法区、直接内存(非运行时数据区的一部分)。
- 程序计数器
程序计数器主要有两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
注意:程序计数器是唯一一个不会出现 OutOfMemoryError
的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
- Java虚拟机栈
Java虚拟机栈是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。除了一些Native 方法外,所有的Java方法调用都是通过栈来实现。栈由一个一个栈帧组成,一个方法调用时,对应的栈帧就会被压入栈中。每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。
局部变量表:存放编译期可知的各种数据类型,以及对象引用。
操作数栈:存放方法执行过程中产生的中间计算结果。
动态链接:主要服务一个方法需要调用其他方法的场景。
栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。
- 本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。
- 堆
堆在虚拟机启动时创建,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代、老年代和永久代。
- 方法区
方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
- 运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字 段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),编译期生成的各种字面量与符号引用将被存放在常量池表中,而常量池表将在类加载后存放到运行时常量池中。
Java对象创建过程
- Step1 类加载检查
虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
- Step2 分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。
内存分配的方式:
- 指针碰撞
适合堆内存规整(即没有内存碎片)的情况下。用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
- 空闲列表
适合堆内存不规整的情况下。虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
在创建对象的时候有一个很重要的问题,就是线程安全,虚拟机采用两种方式来保证线程安全:
- CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。
- Step3 初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)。
- Step4 设置对象头
初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。
- Step5 执行init方法
接着执行 <init>
方法(即构造器方法),把对象按照程序员的意愿进行初始化。
对象的内存布局
对象在内存中的布局可以分为 3 块区域:对象头、实例数据和对齐填充。
对象头: 包含两部分信息,第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据:是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
对齐填充:该部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。
对象的访问定位
Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄、直接指针。
句柄:如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。
直接指针 :如果使用直接指针访问,reference 中存储的直接就是对象的地址。
使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
JVM垃圾回收
垃圾收集算法
垃圾回收算法可以划分为:引用计数式垃圾回收和追踪式垃圾回收。后续介绍的均属于追踪式垃圾回收算法。
- 标记-清除算法
标记-清除算法分为了两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。当然也可以标记处存活的对象。
存在缺陷:
- 执行效率不稳定,标记和清除两个过程的执行效率都随对象数量增长而降低。
- 内存空间碎片化问题,标记、清除之后会产生大量不连续的内存碎片。
- 标记-复制算法
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。进行垃圾回收时,将使用的一块中存活的对象复制到另一块上面,然后一次性清理掉这一块内存空间。
这种方式实现简单,运行高效,缺陷就是可用内存缩减为一半。
- 标记整理算法
标记-整理算法的标记过程和前面的一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
分代收集理论
分代收集理论的设计原则是:**收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。 ** 显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那 么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对 象,就能以较低代价回收到大量的空间。
基于分代收集理论,垃圾回收类型可以分为:
- 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集。
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
Java中将堆内存划分为了如下三部分:
新生代内存:Eden空间、Survivor0空间和Survivor1空间,占比为8:1:1,一般采用标记-复制算法。
老年代:Tenured空间,采用标记-整理算法。
永久代:JDK8之后是元空间,使用的是直接内存。
跨代引用问题
分代收集算法容易存在一个问题:对象之间不是孤立的,对象之间会存在跨代引用问题。
对新生代对象进行垃圾回收时,如果为了找出该区域的存活对象,需要遍历整个老年代对象,该方式虽然可行。但是会为垃圾回收带来更多的性能负担。
此时有一个经验法则:跨代引用相对于同代引用来说仅占极少数。 因为被老年代引用的对象,很大概率会继续存活,使得新生代对象会晋升为老年代,这时候跨代引用就会消失。
因此,解决方案为: 在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
对象已死?
在进行垃圾回收之前,如何判定对象是否存活或死亡呢?
- 引用计数算法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
- 可达性分析算法
这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连, 或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
GC Root对象有哪些?
在Java中,固定可作为GC Root的对象包括以下几种:
- 在虚拟机栈中引用的对象,例如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,例如Java类的引用类型静态变量。
- 在方法区中常量引用的对象,例如字符串常量池(String Table)里的引用。
- 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized关键字)持有的对象。
三色标记-并发的可达性分析
- 白色对象:表示对象尚未被垃圾收集器访问过。若在分析结束仍是白色对象,则表示不可达。
- 灰色对象:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
- 黑色对象:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。
并发条件下,当如下两个条件同时发生时,则会产生对象消失问题:
- 插入了一条或多条从黑色对象到白色对象的新引用
- 全部灰色对象到该白色对象的关系被破坏
因此,要解决并发条件下,对象的消失问题,只需要破坏两个条件的任意一个条件即可。由此分别 产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning, SAT B ) 。
增量更新破坏的是第一个条件,黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再以这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
原始快照破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再以这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
Java引用类型
引用类型如果只区分为「引用」与「被引用」的关系。
如果我们需要描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象——很多系统的缓存功能都符合这样的应用场景。
此时这两种关系显然不够用。
因此Java将引用分为:强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference) 4种,这4种引用强度依次逐渐减弱。
- 强引用: 是指在程序代码之中普遍存在的引用赋值,即类似
Object obj=new Object()
这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。 - 软引用: 用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存, 才会抛出内存溢出异常。
- 弱引用:用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。 当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
- 虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
Java垃圾收集器
下图展示了不同的垃圾收集器工作的代:
- Serial收集器
Serial收集器是单线程工作的收集器,此处的单线程主要是强调进行垃圾回收时,必须STW。
- ParNew收集器
ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。
- Parallel Scavenge收集器
该收集器更加关注吞吐量。目标则是达到一个可控制的吞吐 量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。
- Serial Old收集器
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。
- Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。
- CMS收集器
CMS 收集器是一种 “标记-清除”算法实现的,运作过程有如下四步:
初始标记: 暂停所有的其他线程(STW),并记录下直接与GC Roots 相连的对象,速度很快 ;
并发标记: 从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行,是基于增量更新来做的并发标记。
重新标记: 是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一 些,但也远比并发标记阶段的时间短。
并发清除: GC 线程开始对未标记的区域做清扫,可以与用户线程同时并发。
CMS收集器的优势是:并发收集、低停顿。缺陷是:
对处理器资源非常敏感。因为在并发阶段,会占用一部分线程导致应用程序变慢,降低总的吞吐量。CMS默认启动的回收线程数是**(处理器核心数量 +3)/4** ,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的 处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时, CMS对用户程序的影响就可能变得很大。
无法处理“浮动垃圾”:在进行垃圾回收过程中,由于用户线程也在运行,可能也会产生垃圾,称之为浮动垃圾,本次GC无法处理。
由于是标记-清除算法,则会产生大量内存碎片。
- G1收集器
G1收集器的目标是希望做出一款能够建立起“停顿预测模型”(Pause Prediction Model)的收集器,停顿预测模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。
首先要明确的是,G1收集器的垃圾回收是Mixed GC模式,并不是单独的面向新生代或者老年代,而是面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大。
G1收集器将Java连续的堆空间划分为多个大小相等的独立区域(Region),每个Region可以扮演不同的角色(Eden空间,Survivor空间或者老年代空间),收集器能够对不同的Region采用不同的策略去收集,Region中还有一类特殊的Humongous区域,专门用来存储大对象。
G1收集器的处理思路为:G1收集器跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一 个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region。 大致步骤为:
初始标记:标记GC Roots能直接关联的对象,这个阶段需要STW,但停顿时间很短。
并发标记:并发对堆中对象进行可达性分析。基于原始快照的方式来做的并发标记。
最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
筛选回收:根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
内存分配策略
- 对象优先在Eden分配:大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
- 大对象直接进入老年代:大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。大对象直接进入老年代主要是为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。
- 长期存活的对象将进入老年代:虚拟机给每个对象定义了一个对象年龄(Age)计数器。经过第一次 Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程 度(默认为15),就会被晋升到老年代中。
- 动态对象年龄判定:如果在Survivor空间中相同年龄所有对象大小的总和大于 Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。
- 空间分配担保:在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果条件不成立,则会检查参数设置是否允许担保失败,如果允许,则会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于或者不允许担保失败,那么这时就要改为进行一次Full GC。
风险是指:有可能进行分配担保时,将Survivor空间无法容纳的对象送入老年代时,老年代剩余空间也无法容纳。
注意:担保失败后,将重新发起一次Full GC,虽然这样停顿时间更长,但也是为了避免频繁地Full GC。
类文件结构详解
在 Java 中,JVM 可以理解的代码就叫做字节码
(即扩展名为 .class
的文件),java程序可以通过javac
编译器将源代码编译成.class
字节码文件。Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文件之中,中间没有添加任何分隔符。Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表。
- 无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
- 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名 都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上也可以视 作是一张表。
Class文件结构如下:
ClassFile {
u4 magic; //Class 文件的标志
u2 minor_version; //Class 的小版本号
u2 major_version; //Class 的大版本号
u2 constant_pool_count; //常量池的数量
cp_info constant_pool[constant_pool_count-1]; //常量池
u2 access_flags; //Class 的访问标记
u2 this_class; //当前类
u2 super_class; //父类
u2 interfaces_count; //接口
u2 interfaces[interfaces_count]; //一个类可以实现多个接口
u2 fields_count; //Class 文件的字段属性
field_info fields[fields_count]; //一个类可以有多个字段
u2 methods_count; //Class 文件的方法数量
method_info methods[methods_count]; //一个类可以有个多个方法
u2 attributes_count; //此类的属性表中的属性数
attribute_info attributes[attributes_count]; //属性表集合
}
JVM类加载机制
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最 终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。
类的生命周期
一个类的完整生命周期如下图所示:
类加载过程
- 加载
该过程将通过类加载器获取到定义此类的二进制字节流文件(.class
文件)。
- 验证
验证阶段的目的是确保class文件的字节流包含的信息符合规范,将进行:文件格式验证、元数据验证、字节码验证和符号引用验证。
- 准备
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。
- 解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。
- 初始化
初始化阶段就是执行类构造器<clinit>()
方法的过程。<clinit >()
并不是程序员在Java代码中直接编写 的方法,它是Javac编译器的自动生成物。
<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的。
<clinit>()
方法与类的构造函数(即在虚拟机视角中的实例构造器<init>()
方法)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的<clinit >()
方法执行前,父类的<clinit >()
方法已经执行完毕 。
<clinit >()
方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的 赋值操作,那么编译器可以不为这个类生成<clinit >()
方法
类加载器
类加载器只用于实现类的加载动作,对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类命名空间。
通俗的理解就是:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义。
这里的相等,包括代表类的Class对象的
equals()
方法、isAssignableFrom()
方法、isInstance()
方法的返回结果,也包括了使用instanceof关键字做对象所属关系判定等情况。
双亲委派模型
JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader
:
- BootstrapClassLoader(启动类加载器) :最顶层的加载类,由 C++实现,负责加载
%JAVA_HOME%/lib
目录,或者被-Xbootclasspath
参数指定的路径中的存放的,并且是JVM虚拟机能够识别的类库加载到虚拟机内存中。- 扩展类加载器(Extension Class Loader):负责加载
<JAVA_HOM E>\lib\ext
目录中,或者被java.ext.dirs
系统变量所指定的路径中所有的类库。- 应用程序类加载器(Application Class Loader):负责加载用户类路径 (ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。
双亲委派模型工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
双亲委派模型的好处:
Java中的类随着它的类加载器一起具备了一种带有优先级的层级关系。例如:
java.lang.Object
类,存放在rt.jar中,无论哪一个类加载器需要加载这个类,最终都会委派给处于模型顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载,也保证了 Java 的核心 API 不被篡改。
常用JVM参数与工具
常用JVM参数
- -XX:+PrintGCDetails, -XX:+PrintGCTimeStamps, -XX:+PrintGCDateStamps
用于打印出GC日志,查看GC情况。
- -XX: MaxHeapSize=100M
设置最大堆大小
- -XX:NewSize=20M, -XX:OldSize=50M
设置年轻代,老年代大小