Java基础集合多线程JVM
Java基础
重点记录
构造器 Constructor 是否可被 override?
Constructor 不能被 override(重写),但是可以 overload(重载),所以你可以看到一个类中有多 个构造函数的情况。
重载和重写的区别
重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理。
重写就是子类对父类方法的重新改造,外部样子不能改变,内部逻辑可以改变。
重写发生在运行时。因为在编译时,编译器是无法知道我们到底是调用父类的方法还是子类的方法,相反的,只有在实际运行的时候,我们才知道应该调用哪个方法。
重载发生在编译时。在编译过程中,编译器必须根据参数类型以及长度来确定到底是调用的哪个方法,这也是Java编译时多态的体现。
Java 面向对象编程三大特性: 封装 继承 多态
-
继承
- 子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是⽆法访问,只是拥有。
- 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。
- 子类可以用自己的方式实现父类的方法。(以后介绍)。
-
多态
多态是同一个行为具有多个不同表现形式或形态的能力。多态就是同一个接口,使用不同的实例而执行不同操作(调用同一个方法)。
在 Java 中有两种形式可以实现多态:继承(多个子类对同一方法的重写)和接口(实现接口并覆盖接口中同一方法)。
StringBuffer 和 StringBuilder 的区别是什么? String 为什么是不可变的?
简单的来说:String 类中使用 final 关键字修饰字符数组来保存字符串, private final char value[] ,所以 String 对象是不可变的。
StringBuffer 对方法加了同步锁synchronized或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。
StringBuffer/StringBuilder 每次都会对对象本身进行操作,而不是像String生成新的对象并改变对象引用。
相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。
在 Java 中定义一个不做事且没有参数的构造方法的作用
规定:Java 程序在执行子类的构造方法之前,如果没有用 super() 来调用父类特定的构造方法,则会调用父类中“没有参数的构造方法”。
因此,如果父类中只定义了有参数的构造方法,而在子类的构造方法中又没有用 super() 来调用父类中特定的构造方法,则编译时将发生错误,因为 Java 程序在父类中找不到没有参数的构造方法可供执行。解决办法是在父类里加上一个不做事且没有参数的构造方法。
接口和抽象类的区别是什么?
-
语法层面
- 接口只有定义的方法名;抽象类可以有实现方法。
- 接口成员变量只有public static final;抽象类可以有各种修饰的成员变量。
- 可以实现多个接口;只能继承一个抽象类。
- 接口内不能有静态代码块和静态方法;抽象类可以有。
-
从设计层⾯来说
抽象类是对类的抽象,是一种模板设计,而接口是对行为的抽象,是一种行为的规范。
我的理解:抽象类便于复用,接口便于扩展。
-
接口在不同jdk变化
- 在 jdk 7 或更早版本中,接口里⾯只能有常量变量和抽象方法。这些接口方法必须由选择实现接口的类实现
- jdk 8 的时候接口可以有默认方法和静态方法功能。
- Jdk 9 在接口中引⼊了私有方法和私有静态方法。
成员变量与局部变量的区别有那些?
- 所属:成员变量属于类,局部变量属于方法或者方法参数。
- 修饰符:局部变量不能用修饰符和static,但是都能用final。
- 存储方式:如果成员变量是使用 static 修饰的,那么这个成员变量是属于类的,如果没有使用 static 修饰,这个成员变量是属于实例的。
- 生命周期:成员变量生命周期随对象,局部变量生命周期随方法。
- 赋值:成员变量如果没有被赋初值,则会自动以类型的默认值而赋值(一种情况例外:被 final 修饰 的成员变量也必须显式地赋值),而局部变量则不会自动赋值。
静态方法和实例方法有何不同
- 所属:静态方法属于类;实例方法属于对象。
- 使用:调用静态方法可以⽆需创建对象。
- 访问:静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法);实例方法则⽆此限制。
- 数量:静态只有一个,实例方法数量取决于实例数量。
hashCode 与 equals (重要)
-
拓展阅读
-
Java hashCode() 和 equals()的若干问题解答
文章中有个错误。HashSet不允许有重复,而不是
HashSet允许有重复。
-
-
为什么两个对象有相同的 hashcode 值,它们也不一定是相等的?
因为 hashCode() 所使用的杂凑算法也许刚好会让多个对象传回相同的杂凑值。如果 HashSet 在对比的时候,同样的 hashcode 有多个对象,它会使用 equals() 来判断是否真的相同。也就是说 hashcode 只是用来缩小查找成本。
-
为什么重写equals后要重写hashcode?
重写equals,是为了根据业务需要判断对象是否相等。而涉及到HashMap相关的类,需要用到hashcode。如HashSet的add方法会调用HashMap的put方法,该方法会用hashcode判断key是否相等。如果没有重写hashcode,即便是满足了equals的两个对象,hashcode是不会相等的,因为Object的hashcode是每个对象的内部地址转换成的整型。
为什么 Java 中只有值传递?
Java 程序设计语⾔总是采用按值调用。也就是说,方法得到的是所有参数值的一个拷贝(基本类型的拷贝或者是引用的拷贝)。也就是说,方法不能修改传递给它的任何参数变量的内容,他只能修改拷贝后的参数变量(如拷贝的引用)。
- 一个方法不能修改一个基本数据类型的参数(即数值型或布尔型)。
- 一个方法可以改变一个对象参数的状态(应该是指的修改引用的对象的成员变量)。
- 一个方法不能让对象参数引用一个新的对象。
线程有哪些基本状态?
Java 中的异常处理
什么情况下finally不会执行
- 在 try 或 finally 块中用了 System.exit(int) 退出程序。但是,如果 System.exit(int) 在异常语句之后, finally 还是会被执行。
- 程序所在的线程死亡。
- 关闭 CPU。
Java序列化中如果有些字段不想进行序列化,怎么办?
transient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。transient 只能修饰变量,不能修饰类和方法。
既然有了字节流,为什么还要有字符流?
-
字符编码问题:不同的文本文件可能使用不同的字符编码(如UTF-8、ISO-8859-1等)。使用字节流读写文本数据时,需要手动处理字符编码和解码的问题,这增加了编程的复杂性。
-
效率和便利性:字符流提供了直接读写字符串的方法,这比处理字节更加直观和方便。此外,字符流还可以提高处理文本文件的效率,因为它可以缓冲字符,减少实际的物理读写次数。
Reader和Writer可以一次读写多个字符而不是单个字节,效率更高。
-
二进制文件用字节流读写,文本文件用字符流读写。
Java 中 IO 流分为几种?BIO,NIO,AIO 有什么区别?
这个讲的比较详细 理解什么是BIO/NIO/AIO
-
BIO:同步并阻塞 I/O 模式;
每个请求建立一个线程,开销大。对于低负载、低并发的应用程序,可以使用同步阻塞 I/O 来提升开发速率和更好的维护性;
-
NIO: 同步非阻塞的 I/O 模型 ;
一个线程处理多个连接。对于高负载、高并发的(⽹络)应用,应使用 NIO 的非阻塞模式来开发。 如 聊天服务器,弹幕系统,服务器间通讯等,编程比较复杂 。
-
AIO: 异步非阻塞的 IO 模型
适用于连接数目多且连接比较长的架构,比如相册服务器,充分调用 OS 参与并发操作,编程比较复杂
深拷贝 vs 浅拷贝
Object默认的clone是实现的浅拷贝。需要深拷贝的话可以:
- new
- 实现 Cloneable 接口
- Apache Commons Lang包。先将源对象进行序列化,再反序列化生成拷贝对象。但是,使用序列化的前提是拷贝的类(包括其成员变量)需要实现Serializable接口。
- Gson可以将对象序列化成JSON,也可以将JSON反序列化成对象
- Jackson序列化
- 浅拷贝:对基本数据类型进行值传递,对引用数据类型进行引用传递般的拷贝,此为浅拷贝。
- 深拷贝:对基本数据类型进行值传递,对引用数据类型,创建一个新的对象,并复制其内容,此为深拷贝。
集合
重点记录
列表
- 不安全:ArrayList、LinkedList
- 安全:
-
concurrent 包下的 CopyOnWriteArrayList
内部用Lock和volatile实现。只在可能存在不安全的地方上锁。
final transient ReentrantLock lock = new ReentrantLock();
private transient volatile Object[] array; -
Vector
所有方法用synchronized,性能不行。
-
ArrayList 与 Vector 区别呢?为什么要用Arraylist取代 Vector呢?
底层都是数组实现,但是Vector线程安全。迭代Vector(get()等方法)会用synchronized锁住数组对象,导致性能差。推荐使用CopyOnWriteArrayList,读的时候不锁。
Arraylist 与 LinkedList 区别?
从安全、底层数据结构、插入复杂度、快速随机访问、占用空间
- 是否保证线程安全:ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;
- 底层数据结构:Arraylist 底层使用的是 Object 数组; LinkedList 底层使用的是双向链表数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链 表的区别,下⾯有介绍到!)
- 插⼊和删除是否受元素位置的影响: ① ArrayList 采用数组存储,所以插⼊和删除元素的 时间复杂度受元素位置的影响。 比如:执行 add(E e) 方法的时候, ArrayList 会默认在将 指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插⼊和删除元素的话( add(int index, E element) )时间复杂度就为 O(n-i)。因为在进行上述操 作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。 ② LinkedList 采用链表存储,所以对于 add(E e) 方法的插⼊,删除元素时间复杂度不受元素 位置的影响,近似 O(1),如果是要在指定位置 i 插⼊和删除元素的话( (add(int index, E element) ) 时间复杂度近似为 o(n)) 因为需要先移动到指定位置再插⼊。
- 是否⽀持快速随机访问: LinkedList 不⽀持高效的随机元素访问,而 ArrayList ⽀持。快 速随机访问就是通过元素的序号快速获取元素对象(对应于 get(int index) 方法)。
- 内存空间占用: ArrayList 的空 间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间 (因为要存放直接后继和直接前驱以及数据)。
双向链表和双向循环链表
双向链表头不指向尾,尾不指向头。双向循环列表要。
RandomAccess 接口
RandomAccess 接口是一个标识,没有方法。标识实现这个接口的类具有随机访问功能,如ArrayList。
说一说 ArrayList 的扩容机制吧
- 初始10,最大Integer.MAX_VALUE - 8。
- 每次增加长度为当前长度>>1,大概每次扩容现有一半。
- 采用Arrays.copyOf方法复制到扩容后的数组
HashMap相关资料
-
这个很详细,一定要看
HashMap 和 Hashtable 的区别
从线程安全、效率、Null Key、Null Value支持、初始/扩容、底层数据结构
- 线程是否安全: 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 的幂次方⼤小( HashMap 中的 tableSizeFor() 方法保证)。也就是说 HashMap 总是使用 2 的幂作为哈希表的⼤小,后⾯会介绍到为什么是 2 的幂次方。
- 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较⼤的变化,当链表(链表不是数组)⻓度
⼤于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的⻓度小于 64,那么
会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时
间。Hashtable 没有这样的机制。
HashMap 和 HashSet区别
HashSet 底层就是基于 HashMap 实现的。 ( HashSet 的源码非常非常少,因为除了 clone() 、 writeObject() 、 readObject() 是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。
HashMap的底层实现
有时间阅读下源码确认下
-
JDK1.8之前
JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素 存放的位置(这里的 n 指的是数组的⻓度),如果当前位置存在元素的话,就判断该元素与要存 ⼊的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲 突。
-
JDK1.8之后
当链表⻓度⼤于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的⻓度小于 64,那么会选择先进行数组 扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
HashMap 的⻓度为什么是2的幂次方
计算数组下标用hash%length公式。当length 是2的 n 次方时 hash%length == hash&(length-1)。又因为&运算速度高于%运算,所以为2的幂次方。
首先,一般来说,我们常用的 Hash 函数是这样的:index = HashCode(key) % Length,但是因为位运算的效率比较高嘛,所以 HashMap 就相应的改成了这样:index = HashCode(key) & (Length - 1)。
那么为了保证根据上述公式计算出来的 index 值是分布均匀的,我们就必须保证 Length 是 2 的次幂。
解释一下:2 的次幂,也就是 2 的 n 次方,它的二进制表示就是 1 后面跟着 n 个 0,那么 2 的 n 次方 - 1 的二进制表示就是 n 个 1。而对于 & 操作来说,任何数与 1 做 & 操作的结果都是这个数本身。也就是说,index 的结果等同于 HashCode(key) 后 n 位的值,只要 HashCode 本身是分布均匀的,那么我们这个 Hash 算法的结果就是均匀的。
HashMap 多线程操作导致死循环问题
主要原因在于并发下的 Rehash 会造成元素之间会形成一个循环链表。不过,jdk 1.8 后解决了这 个问题,但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在其 他问题比如数据丢失(多个线程把相同hash的值覆盖,本来是应该挂链表的)。并发环境下推荐使用 ConcurrentHashMap 。
ConcurrentHashMap 和 Hashtable 的区别
ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。
- 底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑⼆叉树。 Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
- 实现线程安全的方式(重要): ① 在 JDK1.7 的时候, ConcurrentHashMap (分段锁) 对整个桶数组进行了分割分段( Segment ),每一把锁只锁容器其中一部分数据,多线程访问 容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经 摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发 控制使用 synchronized 和 CAS( AtomicInteger等) 来操作。② Hashtable (同一把 锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其 他线程也访问同步方法,可能会进⼊阻塞或轮询状态,如使用 put 添加元素,另一个线程不 能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
ConcurrentHashMap线程安全的具体实现方式/底层具体实现
- JDK1.7
首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个 段数据时,其他段的数据也能被其他线程访问。一个 Segment 包含一个 HashEntry 数组 。
- JDK1.8
取消了 Segment 分段锁,采用 CAS 和 synchronized 来保证并发安全。数 据结构跟 HashMap1.8 的结构类似,数组+链表/红黑⼆叉树。Java 8 在链表⻓度超过一定阈值 (8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N))) synchronized 只锁定当前链表或红黑⼆叉树的首节点,这样只要 hash 不冲突,就不会产生并 发,效率又提升 N 倍。
集合框架底层数据结构总结
List
- List Arraylist : Object[] 数组
- Vector : Object[] 数组
- LinkedList : 双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)
Set
- HashSet (⽆序,唯一): 基于 HashMap 实现的,底层采用 HashMap 来保存元素
- LinkedHashSet : LinkedHashSet 是 HashSet 的子类,并且其内部是通过 LinkedHashMap 来实现的。有点类似于我们之前说的 LinkedHashMap 其内部是基于 HashMap 实现一样, 不过还是有一点点区别的
- TreeSet (有序,唯一): 红黑树(自平衡的排序⼆叉树) 再来看看 Map 接口下⾯的集合。
Map
- HashMap : JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链 表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突 时有了较⼤的变化,当链表⻓度⼤于阈值(默认为 8)(将链表转换成红黑树前会判断,如 果当前数组的⻓度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链 表转化为红黑树,以减少搜索时间
- LinkedHashMap : LinkedHashMap 继承自 HashMap ,所以它的底层仍然是基于拉链式散 列结构即由数组和链表或红黑树组成。另外, LinkedHashMap 在上⾯结构的基础上,增加 了一条双向链表,使得上⾯的结构可以保持键值对的插⼊顺序。同时通过对链表进行相应的 操作,实现了访问顺序相关逻辑。详细可以查看:《LinkedHashMap 源码详细分析 (JDK1.8)》
- Hashtable : 数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲 突而存在的 TreeMap : 红黑树(自平衡的排序⼆叉树)
多线程
知识点
volatile
-
可见性、有序性;没有原子性。
每个线程有自己的变量拷贝。如果一个变量被
volatile
所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile
在并发编程中,其值在多个缓存中是可见的。
CAS
Compare and Swap ,字面以是就是比较如果相同就替换。
-
这个讲的比较清晰
重点记录
进程、线程
与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序 计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作 时,负担要比进程小得多。
Java内存区域
运行时区域
-
程序计数器
-
Java虚拟栈
-
本地方法栈
-
堆
-
方法区
-
运行常量池
-
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域
NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用Native函数库直接分配堆外内存
本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
程序计数器为什么是私有的
虚拟机栈和本地方法栈为什么是私有的
-
虚拟机栈
每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、 常量池引用等信息。
-
本地方法栈
类似虚拟机栈,他是为Native本地方法服务。
为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
一句话简单了解堆和方法区
说说并发与并行的区别
- 并发: 同一时间段,多个任务都在执行 (单位时间内不一定同时执行);
- 并行: 单位时间内,多个任务同时执行。
为什么要使用多线程呢
- 线程间切换成本比进程高
- 应对高并发
- 单核处理器:两个线程可以充分利用CPU和IO
- 多核处理器:充分利用多个核心
使用多线程可能带来什么问题
内存泄漏、死锁、上下文切换导致消耗时间
什么是线程死锁?如何避免死锁?
-
线程死锁
多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被⽆限期地阻塞,因此程序不可能正常终止。
-
产生死锁必须具备以下四个条件:
- 互斥条件:该资源任意一个时刻只由一个线程占用。
- 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
说说 sleep() 方法和 wait() 方法区别和共同点?
- 两者最主要的区别在于: sleep() 方法没有释放锁,而 wait() 方法释放了锁 。
- 两者都可以暂停线程的执行。
- wait() 通常被用于线程间交互/通信, sleep() 通常被用于暂停执行。
- wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或 者 notifyAll() 方法。 sleep() 方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout) 超时后线程会自动苏醒。
为什么我们调用 start() 方法时会执行 run() 方法,为什么我 们不能直接调用 run() 方法?
调用 start() 方法方可启动线程并使线程进⼊就绪状态,直接执行 run() 方法的话不会 以多线程的方式执行。
说一说自己对于 synchronized 关键字的了解
synchronized 关键字解决的是多个线程之间访问资源的同步性, synchronized 关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
在 Java 早期版本中, synchronized 属于重量级锁,效率低下。为什么呢? 因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较⻓的时间,时间成本相对较高。
庆幸的是在 Java 6 之后 Java 官方对从 JVM 层⾯对 synchronized 较⼤优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6 对锁的实现引⼊了⼤量的优化,如自旋锁、适 应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。 所以,你会发现⽬前的话,不论是各种开源框架还是 JDK 源码都⼤量使用了 synchronized 关键 字。
说说自己是怎么使用 synchronized 关键字
synchronized 关键字最主要的三种使用方式:
-
修饰实例方法: 作用于当前对象实例加锁,进⼊同步代码前要获得当前对象实例的锁
-
修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例 ,进⼊同步代码前要获得 当 前 class 的锁。
-
修饰代码块 :指定加锁对象,对给定对象/类加锁。
总结:
- synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁。
- synchronized 关键字加到实例方法上是给对象实例上锁。
- 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能!
双重校验锁实现对象单例(线程安全)
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {}
public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进⼊加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
第二次判断singleton是否为null
第二次判断是为了避免以下情况的发生。
- 假设:线程A已经经过第一次判断,判断singleton=null,准备进入同步代码块.
- 此时线程B获得时间片,犹豫线程A并没有创建实例,所以,判断singleton仍然=null,所以线程B创建了实例singleton。
- 此时,线程A再次获得时间片,犹豫刚刚经过第一次判断singleton=null(不会重复判断),进入同步代码块,这个时候,我们如果不加入第二次判断的话,那么线程A又会创造一个实例singleton,就不满足我们的单例模式的要求,所以第二次判断是很有必要的。
为什么要加 Volatile 关键字
需要注意 uniqueInstance 采用 volatile 关键字修饰也是很有必要。 niqueInstance = new Singleton(); 这 段代码其实是分为三步执行:
- 为 uniqueInstance 分配内存空间
- 初始化 uniqueInstance
- 将 uniqueInstance 指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不 会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执 行了 1 和 3,此时 T2 调用 getUniqueInstance () 后发现 uniqueInstance 不为空,因此返回 uniqueInstance ,但此时 uniqueInstance 还未被初始化。使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
构造方法可以使用 synchronized 关键字修饰么?
不行,构造方法本来就是线程安全的
讲一下 synchronized 关键字的底层原理
-
修饰代码块
synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指明同步代码块的结束位置。
在执行 monitorenter 时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
-
修饰方法
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调 用。
-
总结
synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指明同步代码块的结束位 置。 synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。 不过两者的本质都是对对象监视器 monitor 的获取。
说说 synchronized 关键字和 volatile 关键字的区别
- volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比 synchronized 关键字要好。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块。
- volatile 关键字能保证数据的可⻅性,但不能保证数据的原子性。 synchronized 关键字两 者都能保证。
- volatile 关键字主要用于解决变量在多个线程之间的可⻅性,而 synchronized 关键字解决 的是多个线程之间访问资源的同步性。
为什么要弄一个 CPU 高速缓存呢?
有空要看一下高速缓存和多线程联系的相关资料
CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。而内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。
-
CPU Cache 的工作方式
先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数 据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 内存缓存不一致性的问题 !比如我执行一个 i++操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 1++运算完之后再写回 Main Memory 之后 i=2,而正确结果 应该是 i=3。
CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议或者其他⼿段来解决。
讲一下 JMM(Java 内存模型)
在当前的 Java 内存模型下,线程可以把变量保存本地内存(比如机器的寄存器) 中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。要解决这个问题,就需要把变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的, 每次使用它都到主存中进行读取。
所以,volatile 关键字除了防止 JVM 的指令重排 ,还有一个重要的作用就是保证变量的可⻅性。
ThreadLocal 原理讲一下
-
Thread 类中有一个 threadLocals 和 一个 inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量。
-
当前线程调用 ThreadLocal 类的 set 或 get 方法时,调用的是 ThreadLocalMap 类对应的 get() 、 set() 方法。
-
最终的变量是放在了当前线程的 ThreadLocalMap 中。ThreadLoal对象为key,如果有多个ThreadLoal对象,就有多个key。
ThreadLocal 内存泄露问题了解不?
ThreadLocal有内存泄漏风险
ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会 被清理掉。这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远⽆法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现 中已经考虑了这种情况,在调用 set() 、 get() 、 remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal 方法后 最好⼿动调用 remove() 方法
-
弱引用
如果一个对象只具有弱引用,那就类似于可有可⽆的生活用品。弱引用与软引用的区别在 于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存 区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间⾜够与否,都会回收它 的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只 具有弱引用的对象。 弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃 圾回收,Java 虚拟机就会把这个弱引用加⼊到与之关联的引用队列中。
为什么 ThreadLocalMap 的 key 要用弱引用
-
弱引用在ThreadLocal没有外部引用的时候,会回收ThreadLocalMap中的key,减少内存溢出风险。
-
ThreadLocalMap中的set、get、remove等方法会把key为null的 Entry 映射回收,包括value。
ThreadLocal的建议使用方法:
- 当线程的某个ThreadLocal对象使用完了,马上调用remove方法,删除Entry对象。
- 设计为static的,被class对象给强引用,线程存活期间就不会被回收,也不用remove,完全不用担心内存泄漏
- 设计为非static的,长对象(比如被spring管理的对象)的内部,也不会被回收
- 没必要在方法中创建ThreadLocal对象
线程池
博客
为什么要用线程池
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能⽴即执行。
- 提高线程的可管理性。线程是稀缺资源,如果⽆限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
实现 Runnable 接口和 Callable 接口的区别
Runnable 接口不会返回结果或抛出检查异常,但是 Callable 接口可以。所以,如果任务不需要返回结果或抛出异常推荐使用 Runnable 接口,这样代码看起来会 更加简洁。
执行 execute()方法和 submit()方法的区别是什么呢?
- execute() 方法用于提交不需要返回值的任务,所以⽆法判断任务是否被线程池执行成功与否;
- submit() 方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Future 的 get() 方法来获取 返回值, get() 方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit) 方法则会阻塞当前线程一段时间后⽴即返回,这时候有可能任务没有执行完。
线程池创建方式
- 用Executors创建
- 用ThreadPoolExecutor构造器
ThreadPoolExecutor 类分析
-
ThreadPoolExecutor 构造函数参数分析
-
corePoolSize : 核⼼线程数线程数定义了最小可以同时运行的线程数量。
-
maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最⼤线程数。
-
workQueue : 当新任务来的时候会先判断当前运行的线程数量是否达到核⼼线程数,如果达到的话,新任务就会被存放在队列中。若不指定大小,其大小有Integer.MAX_VALUE来决定。 无界队列LinkedBlockingQueue使maximumPoolSize 失效。
-
keepAliveTime :当线程池中的线程数量⼤于 corePoolSize 的时候,如果这时没有新的任务 提交,核⼼线程外的线程不会⽴即销毁,而是会等待,直到等待的时间超过了 keepAliveTime 才会被回收销毁;
-
unit : keepAliveTime 参数的时间单位。
-
threadFactory :executor 创建新线程的时候会用到。
-
handler :饱和策略。关于饱和策略下⾯单独介绍一下。
-
-
ThreadPoolExecutor 饱和策略
-
ThreadPoolExecutor.AbortPolicy :抛出 RejectedExecutionException 来拒绝新任务的处理。
-
ThreadPoolExecutor.CallerRunsPolicy :调用执行自己的线程运行任务。您不会任务请求。 但是这种策略会降低对于新任务提交速度,影响程序的整体性能。另外,这个策略喜欢增加 队列容量。如果您的应用程序可以承受此延迟并且你不能任务丢弃任何一个任务请求的话, 你可以选择这个策略。
-
ThreadPoolExecutor.DiscardPolicy : 不处理新任务,直接丢弃掉。
-
ThreadPoolExecutor.DiscardOldestPolicy : 此策略将丢弃最早的未处理的任务请求。
在默认情况下, ThreadPoolExecutor 将抛出 RejectedExecutionException 来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。 对于可伸 缩的应用程序,建议使用 ThreadPoolExecutor.CallerRunsPolicy 。当最⼤池被填满时,此策略为我 们提供可伸缩队列。
线程池原理分析
图只是一个大概,具体还是要去看execute源码
介绍一下 Atomic 原子类
看上面的CAS博客
-
Atomic 翻译成中⽂是原子的意思。在我们这里 Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。 所以,所谓原子类说简单点就是具有原子/原子操作特征的类。
-
并发包 java.util.concurrent 的原子类都存放在 java.util.concurrent.atomic 下。
-
按照大类划分
-
基本类型:使用原子的方式更新基本类型
-
AtomicInteger :整形原子类
-
AtomicLong :⻓整型原子类
-
AtomicBoolean :布尔型原子类
-
数组类型:使用原子的方式更新数组里的某个元素
- AtomicIntegerArray :整形数组原子类
- AtomicLongArray :⻓整形数组原子类
- AtomicReferenceArray :引用类型数组原子类
-
引用类型
- AtomicReference :引用类型原子类
- AtomicStampedReference :原子更新带有版本号的引用类型。该类将整数值与引用关联起 来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能 出现的 ABA 问题。
- AtomicMarkableReference :原子更新带有标记位的引用类型
-
对象的属性修改类型
-
AtomicIntegerFieldUpdater :原子更新整形字段的更新器
-
AtomicLongFieldUpdater :原子更新⻓整形字段的更新器
-
AtomicReferenceFieldUpdater :原子更新引用类型字段的更新器
-
AQS 了解么?
有空看下
- AQS 的全称为( AbstractQueuedSynchronizer ),这个类在 java.util.concurrent.locks 包下⾯。
- AQS 是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出应用广泛的⼤量的同步器,比如我们提到的 ReentrantLock , Semaphore ,其他的诸如 ReentrantReadWriteLock , SynchronousQueue, FutureTask 等等皆是基于 AQS 的。
AQS 原理了解么?
AQS 原理概览
AQS 核⼼思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁 的线程加⼊到队列中。
CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在 队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。

AQS 使用一个 int 成员变量来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队 工作。AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。
AQS 定义两种资源共享方式
- Exclusive(独占):只有一个线程能执行,如 ReentrantLock 。又可分为公平锁和非公平 锁: 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁 非公平锁:当线程要获取锁时,⽆视队列顺序直接去抢锁,谁抢到就是谁的
- Share(共享):多个线程可同时执行,如 CountDownLatch 、 Semaphore 、 CountDownLatch 、 CyclicBarrier 、 ReadWriteLock 我们 都会在后⾯讲到。
AQS 底层使用了模板方法模式
同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样
- 使用者继承 AbstractQueuedSynchronizer 并重写指定的方法。(这些重写方法很简单,⽆非 是对于共享资源 state 的获取和释放)
- 将 AQS 组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用 者重写的方法。
用过 CountDownLatch 么?什么场景下用的?
-
CountDownLatch 的作用就是 允许 count 个线程阻塞在一个地方,直⾄所有线程的任务都执行完 毕。
-
当需要拿到多个线程的执行返回结果时(如调用多个算法做算法比对),可以用CountDownLatch。
-
可以使用 CompletableFuture 类来改进
//⽂件夹位置
List<String> filePaths = Arrays.asList(...);
// 异步处理所有⽂件
List<CompletableFuture<String>> fileFutures = filePaths.stream()
.map(filePath -> doSomeThing(filePath))
.collect(Collectors.toList());
// 将他们合并起来
CompletableFuture<Void> allFutures = CompletableFuture.allOf(
fileFutures.toArray(new CompletableFuture[fileFutures.size()])
);
JVM
知识点
-
很详细,看样子大部分是通过《深入理解Java虚拟机》总结来的。
重点记录
介绍下 Java 内存区域(运行时数据区)
Java内存区域做了简单记录
-
程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时 候能够知道该线程上次运行到哪⼉了。
它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
-
Java 虚拟机栈
与程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型,每次方法调用的数据都是通过栈传递的。
堆栈的栈就是现在说的虚拟机栈, 或者说是虚拟机栈中局部变量表部分。局部变量表主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、 long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
-
本地方法栈
虚拟机栈为虚拟机执行 Java 方法 (也就是字节 码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。
-
堆
Java 虚拟机所管理的内存中最⼤的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一⽬的就是存放对象实例,⼏乎所有的对象实例以及数组都在这里分配内存。 从jdk 1.7开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外⾯使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和⽼年代:再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的⽬的是更好地回收内存,或者更快地分配内存。
-
方法区
方法区是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),⽬的应该是与 Java 堆区分开来。方法区也被称为永久代。
-
方法区和永久代的关系
永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现其他的虚拟机实现并没有永久代这一说法。
-
为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
-
减少内存异常概率:整个永久代有一个 JVM 本身设置固定⼤小上限,⽆法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的⼏率会更小。
如果不指定⼤小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。
-
能加载更多类:元空间里⾯存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
-
在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东⻄, 合并之后就没有必要额外的设置这么一个永久代的地方了。
-
-
-
运行常量池
-
运行时常量池是方法区的一部分。存放:Class ⽂件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字⾯量和符号引用)
-
运行时常量池与字符串常量池
-
JDK1.7之前,运行时常量池逻辑包含字符串常量池,存放在方法区。此时hotspot虚拟机对方法区的实现为永久代。
-
JDK1.7 字符串常量池被从方法区拿到了堆中,这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东⻄还在方法区(hotspot中的永久代) 。
-
JDK1.8 hotspot移除了永久代,用元空间(Metaspace)取而代之。这时候字符串常量池还在堆,运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间 (Metaspace) 。
-
-
直接内存
有空了解下
Java对象创建过程
-
类加载检查
虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
-
分配内存
对象所需的内存⼤小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定⼤小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能( GC 收集器的算法是标记-整理算法、复制算法;标记-清除算法不带压缩整理)决定。
PS:内存分配并发问题
有空了解下
- CAS+失败重试
- TLAB
-
初始化零值
将分配到的内存空间都初始化为零值(不包括对象头)
-
设置对象头
例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄、锁等信息。
-
执行init方法
对象的访问定位有几种方式
Java程序通过栈上的 reference 数据来操作堆上的具体对象。两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
-
句柄
如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;
-
直接指针
如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。
简单聊聊JVM的内存分配和回收
-
分配
[介绍下 Java 内存区域(运行时数据区)](#介绍下 Java 内存区域(运行时数据区))
-
堆回收
堆是GC回收的主要区域。大多对象在Eden创建,当Eden没有足够的空间分配时,会进行一次新生代回收,Minor GC 将存活的对象复制到Survivor0,然后Eden被清空。当Eden再次没有足够的空间分配时,Minor GC 将Eden和Survivor0存活的对象复制到Survivor1,清空Eden和Survivor0。然后Survivor1和Survivor0交换角色,下次 Minor GC 将存活的对象放到Survivor0中。每次 Minor GC 都会将存活的对象年龄+1,达到15或者通过动态对象年龄判定,这个年龄的及以上的全部拷贝到老年代并删除新生代中的数据。
说一下堆内存中对象的分配的基本策略
-
对象优先在Eden分配
-
大对象(如很长的字符串数组)可以直接进入老年代
目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。
-
长期存活的对象进入老年代
-
动态对象年龄判定
如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。
-
空间分配担保
《深入理解Java虚拟机第三版》
在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次FullGC。
GC的分类
有空看下GC
针对 HotSpot VM 的实现,它里⾯的 GC 其实准确分类只有两⼤种
- 部分收集 (Partial GC): 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集; ⽼年代收集(Major GC / Old GC):只对⽼年代进行垃圾收集。需要注意的是 Major GC 在 有的语境中也用于指代整堆收集;
- 混合收集(Mixed GC):对整个新生代和部分⽼年代进行垃圾收集。 整堆收集 (Full GC):收集整个 Java 堆和方法区。
GC是否回收方法区
方法区和堆一样,都是线程共享的内存区域。方法区被用于存储已被虚拟机加载的类信息、即时编译后的代码、静态变量和常量等数据。
根据Java虚拟机规范的规定,方法区无法满足内存分配需求时,也会抛出OutOfMemoryError异常,虽然规范规定虚拟机可以不实现垃圾收集,因为和堆的垃圾回收效率相比,方法区的回收效率实在太低,但是此部分内存区域也是可以被回收的。方法区的垃圾回收主要有两种,分别是对废弃常量的回收和对无用类的回收。
-
常量回收
当一个常量对象不再任何地方被引用的时候,则被标记为废弃常量,这个常量可以被回收。
-
方法区中的类需要同时满足以下三个条件才能被标记为无用的类
- Java堆中不存在该类的任何实例对象;
- 加载该类的类加载器 ClassLoader 已经被回收;
- 该类对应的java.lang.Class对象不在任何地方被引用,且无法在任何地方通过反射访问该类的方法。
当满足上述三个条件的类才可以被回收,但是并不是一定会被回收,需要参数进行控制,例如HotSpot虚拟机提供了-Xnoclassgc参数进行控制是否回收。
如何判断对象是否死亡?/JVM如何判定一个对象是否应该被回收?
没有被引用的对象就是死亡对象。
-
引用计数法
是一种比较古老的回收算法。原理是此对象有一个引用,即增加一个计数,删除一个引用则减少一个计数。垃圾回收时,只需要收集计数为0的对象。此算法最致命的是无法处理循环引用的问题。
-
可达性分析
基本思路就是通过一系列可以做为root的对象作为起始点,从这些节点开始向下搜索。当一个对象到root节点没有任何引用链接时,则证明此对象是可以被回收的。以下对象会被认为是root对象:
- 栈内存中引用的对象
- 方法区中静态引用和常量引用指向的对象
- 被启动类(bootstrap加载器)加载的类和创建的对象
- Native方法中JNI引用的对象。
简单的介绍一下强引用、软引用、弱引用、虚引用
- 有空看下引用队列和软/弱引用使用
-
强引用 (StrongReference)
大部分都是强引用。虚拟机抛出OutOfMemoryError错误也不会回收内存。
-
软引用 (SoftReference)
如果内存不足,垃圾回收器会回收内存。软引用可用来实现内存敏感的高速缓存。
-
弱引用(WeakReference)
垃圾回收器一旦发现了只具有弱引用的对象,不管当前内存空间⾜够与否,都会回收它的内存。
-
虚引用
有空了解下
虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加⼊到与之关联的引用队列中。程序可以通过判断引用队列中是 否已经加⼊了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加⼊到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
注意:在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出 (OutOfMemory)等问题的产生。
垃圾收集有哪些算法,各自的特点?
《深入理解Java虚拟机第二版》
-
标记-清除算法
首先标记出所有需要回收的对象,然后在标记完成后统一回收所有被标记的对象。
- 效率问题,标记和清除两个过程的效率都不高;
- 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
-
复制算法
将可用内存按容量划分为大小相等的两块(有可能存在100%对象都存在的极端情况),每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
现在商业虚拟机采用这种方式来回收新生代。由于98%的对象创建后很快死亡,Eden和两个Survivor的比例为8:1:1,但是如果Survivor空间不够,需要老生代做内存担保,这样设计保证只有10%的空间被浪费。
-
实现简单、运行高效。使用内存连续,分配内存只需要移动指针。
-
只有一半空间被使用,浪费空间。
-
-
标记整理算法
首先标记出所有存活的对象,然后将存活对象向一端移动,清理掉端边外的内存。
现在的老生代采用该算法。
-
分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。
HotSpot 为什么要分为新生代和老年代?
主要是为了提升 GC 效率。上⾯提到的分代收集算法已经很好的解释了这个问题。
经典垃圾收集器
-
Serial收集器(新生代)
-
这个收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。
-
标记-复制算法
-
简单高效、适用于客户端模式下的虚拟机。
-
-
ParNew收集器(新生代)
-
ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余完全一致。
-
标记-复制算法
-
除了Serial收集器外,目前只有它能与CMS收集器配合工作。
-
-
Parallel Scavenge收集器(新生代)
- 能够并行收集的多线程收集器
- 目标是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。
- 标记-复制算法
- JDK1.8默认
-
Serial Old收集器(老年代)
-
是一个单线程收集器。
-
使用标记-整理算法。
-
简单高效、适用于客户端模式下的虚拟机。
-
用途:一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用[插图],另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。
-
-
Parallel Old收集器(老年代)
- 支持多线程并发收集
- 标记-整理算法
- 在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。
- JDK1.8默认
-
CMS收集器(老年代)
-
是一种以获取最短回收停顿时间为目标的收集器。
-
适用于互联网网站或者基于浏览器的B/S系统的服务端。
-
标记-清除算法
- 初始标记:暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
- 并发标记:同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程⽆法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
- 重新标记:重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍⻓,远远比并发标记阶段时间短
- 并发清除:开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
-
优点
并发收集、低停顿
-
缺点
- 对 CPU 资源敏感,会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量;
- ⽆法处理浮动垃圾(在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生);
- 它使用的回收算法-“标记-清除”算法会导致收集结束时会有⼤量空间碎⽚产生。
-
-
Garbage First收集器(G1)
- 是一款⾯向服务器的垃圾收集器,主要针对配备多颗处理器及⼤容量内存的机器。以极高概率满⾜ GC 停顿时间要求的同时,还具备高吞吐量性能特征.
-
被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。 JDK1.9默认使用。
- 把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理。
- 通过将JVM堆分为一个个的区域(region),G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些Region。
- 特点
- 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核⼼)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执 行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
- 分代收集:虽然 G1 可以不需要其他收集器配合就能独⽴管理整个 GC 堆,但是还是保留了分代的概念。
- 空间整合:与 CMS 的“标记--清理”算法不同,G1 从整体来看是基于“标记整理”算法实现的收集器;从局部上(两个Region)来看是基于“复制”算法实现的。
- 可预测的停顿:这是 G1 相对于 CMS 的另一个⼤优势,降低停顿时间是 G1 和 CMS 共同 的关注点,但 G1 除了追求低停顿外,还能建⽴可预测的停顿时间模型,能让使用者明确指 定在一个⻓度为 M 毫秒的时间⽚段内。
- G1收集器的运作过程大致可划分为以下四个步骤
- 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
- 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
- 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
- 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
- 缺点
- 卡表复杂,导致额外占用堆空间20%以上