我自己的java大全
2024-06-11 17:02:36 32 举报
AI智能生成
我自己的java大全
作者其他创作
大纲/内容
数据结构
算法
设计模式
子主题
JAVA基础知识
数据类型
boolean
String
String,StringBuffer和StringBuilder
线程安全:StringBuffer:线程安全,StringBuilder:线程不安全。因为 StringBuffer 的所有公开方法都是 synchronized 修饰的,而 StringBuilder 并没有 synchronized 修饰。
线程安全
缓冲区:StringBuffer 每次获取 toString 都会直接使用缓存区的 toStringCache 值来构造一个字符串。
StringBuilder 则每次都需要复制一次字符数组,再构造一个字符串。
所以, StringBuffer 对缓存区优化,不过 StringBuffer 的这个toString 方法仍然是同步的。
StringBuilder 则每次都需要复制一次字符数组,再构造一个字符串。
所以, StringBuffer 对缓存区优化,不过 StringBuffer 的这个toString 方法仍然是同步的。
性能:(通常情况下):StringBuilder > StringBuffer > String
Java中对String对象进行的操作实际上是一个不断创建并回收对象的过程,因此在运行速度上很慢
但是一行加起来,比新建两行再加快
而StringBuilder和StringBuffer的对象是变量,对变量的操作是直接对该对象就行更改,因此不会进行反复的创建和回收。所以在运行速度上比较快。
相关结论:StringBuilder性能不比StringBuffer,所以正常性能压力不在StringBuffer上时,还是用它比较好
相关问题
拼写为什么不用+,而是用StringBuffer
代码
解释:上述代码先创建一个String对象str,并赋值abc给str,然后运行到第三行,JVM会再创建一个新的str对象,并将原有str的值和de加起来再赋值给新的str。而第一个创建的str对象被JVM的垃圾回收机制(GC)回收掉。所以str实际上并没有被更改,即String对象一旦创建就不可更改。所以Java中对String对象进行的操作实际上是一个不断创建并回收对象的过程,因此在运行速度上很慢。而StringBuilder和StringBuffer的对象是变量,对变量的操作是直接对该对象就行更改,因此不会进行反复的创建和回收。所以在运行速度上比较快。
String s = new String("abc");创建几个对象
2个对象。s首先会在常量池创建“abc”字符串常量,当new的时候就会在堆内存中创建一个对象,此时会把常量池中的字符串常量拷贝一份副本到给到堆内存中的对象,堆内存中的这个对象就会把地址值赋给s。常量池中对象的地址值和堆内存中对象的地址值是不一样的,s指向的是堆内存中的对象,不是常量池中的对象。此时堆内存中有一个对象,常量池中有一个对象,所以创建了2个对象。查看API,String的有参构造。
“a”、“b”、“c”和“abc”相等判断
Java中有常量优化机制,“a”、“b”、“c”本身就是字符串常量,所以在编译时,"a"+"b"+"c"就是“abc”字符串,所以就在常量池创建了“abc”字符串,当执行s2的时候,此时常量池中已经存在了“abc”,所以==号比较返回true。equals方法比较毫无疑问是true。
判断定义为String类型的s3和s2是否相等
s1+"c"中s1不是常量,所以不能用常量优化机制来分析。equals方法比较毫无疑问是true。
s1和s2会分别在常量池中创创建"ab"、"abc"两个对象
s3的时候是字符串串联,API解释如下图。所以s3的时候会在对内存中创建StringBuilder(或者StringBuffer)对象,通过append方法拼接成“abc”对象,此时的“abc”是StringBuilder(或者StringBuffer)类型的,通过调用toString方法转成String对象,此时s3指向的是堆内存中这个String对象
s2指向的是常量池中的对象,s3指向的是堆内存中的对象,所以==号比较返回false
s1和s2会分别在常量池中创创建"ab"、"abc"两个对象
s3的时候是字符串串联,API解释如下图。所以s3的时候会在对内存中创建StringBuilder(或者StringBuffer)对象,通过append方法拼接成“abc”对象,此时的“abc”是StringBuilder(或者StringBuffer)类型的,通过调用toString方法转成String对象,此时s3指向的是堆内存中这个String对象
s2指向的是常量池中的对象,s3指向的是堆内存中的对象,所以==号比较返回false
为什么Java语言的开发者,把String类定义为final的呢?
因为只有当字符串是不可变的,字符串池才有可能实现。字符串池的实现可以在运行时节约很多heap空间,因为不同的字符串变量都指向池中的同一个字符串。但如果字符串是可变的,那么String interning将不能实现,因为这样的话,如果变量改变了它的值,那么其它指向这个值的变量的值也会一起改变。如果字符串是可变的,那么会引起很严重的安全问题。譬如,数据库的用户名、密码都是以字符串的形式传入来获得数据库的连接,或者在socket编程中,主机名和端口都是以字符串的形式传入。因为字符串是不可变的,所以它的值是不可改变的,否则黑客们可以钻到空子,改变字符串指向的对象的值,造成安全漏洞。
因为字符串是不可变的,所以是多线程安全的,同一个字符串实例可以被多个线程共享。这样便不用因为线程安全问题而使用同步。字符串自己便是线程安全的。
因为字符串是不可变的,所以在它创建的时候HashCode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
因为字符串是不可变的,所以是多线程安全的,同一个字符串实例可以被多个线程共享。这样便不用因为线程安全问题而使用同步。字符串自己便是线程安全的。
因为字符串是不可变的,所以在它创建的时候HashCode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
包装类
基本概念
包装类作为引用类型,可以存在 NULL 值
所有的包装类,都是 final 不可变类,这点与 String 是一致的,任何的修改都是新对象的创建
缓存机制的来源
final 不可变,赋值与声明同步,此后不可修改。如 Integer、String 这些不可变类,是借由 final 完成的修饰。
所以,若需要修改其中的内容,必须开辟新的内存空间,这会造成不必要的浪费,也是各种缓存机制存在的必然
所以,若需要修改其中的内容,必须开辟新的内存空间,这会造成不必要的浪费,也是各种缓存机制存在的必然
缓存池
有和无
有:Byte、Short、Integer、Long、Character
无:Float、Double 与 Boolean
Object
hashCode方法
该方法用于哈希查找,可以减少在查找中使用equals的次数,重写了equals方法一般都要重写hashCode方法。这个方法在一些具有哈希功能的Collection中用到。
一般必须满足obj1.equals(obj2)==true。可以推出obj1.hash- Code()==obj2.hashCode(),但是hashCode相等不一定就满足equals。不过为了提高效率,应该尽量使上面两个条件接近等价。如果不重写hashcode(),在HashSet中添加两个equals的对象,会将两个对象都加入进去。
一般必须满足obj1.equals(obj2)==true。可以推出obj1.hash- Code()==obj2.hashCode(),但是hashCode相等不一定就满足equals。不过为了提高效率,应该尽量使上面两个条件接近等价。如果不重写hashcode(),在HashSet中添加两个equals的对象,会将两个对象都加入进去。
在对象的内存地址基础上经过特定算法返回一个hash码
==和equal区别
1、 ==是判断两个变量或实例是不是指向同一个内存空间 equals是判断两个变量或实例所指向的内存空间的值是不是相同
2、 ==是指对内存地址进行比较 equals()是对字符串的内容进行比较
3、==指引用是否相同 equals()指的是值是否相同
2、 ==是指对内存地址进行比较 equals()是对字符串的内容进行比较
3、==指引用是否相同 equals()指的是值是否相同
特殊情况
【强制】所有的相同类型的包装类对象之间值的比较,全部使用 equals 方法比较。
说明:对于 Integer var = ? 在-128 至 127 范围内的赋值,Integer 对象是在
IntegerCache.cache 产生,会复用已有对象,这个区间内的 Integer 值可以直接使用==进行
判断,但是这个区间之外的所有数据,都会在堆上产生,并不会复用已有对象,这是一个大坑,
推荐使用 equals 方法进行判断。
说明:对于 Integer var = ? 在-128 至 127 范围内的赋值,Integer 对象是在
IntegerCache.cache 产生,会复用已有对象,这个区间内的 Integer 值可以直接使用==进行
判断,但是这个区间之外的所有数据,都会在堆上产生,并不会复用已有对象,这是一个大坑,
推荐使用 equals 方法进行判断。
总结
==判断是不是同一个
equal判断值是不是一样
equal判断值是不是一样
为什么重写equal
因为如果不重写equals方法,当将自定义对象放到 map或者 set中时;如果这时两个对象的 hashCode相同,就会调用 equals方法进行比较,这个时候会调用 Object中默认的 equals方法,而默认的 equals方法只是比较了两个对象的引用是否指向了同一个对象(return (this == obj);),显然大多数时候都不会指向,这样就会将重复对象存入 map或者 set中。这就破坏了 map与 set不能存储重复对象的特性,会造成内存溢出。
为什么重写equal后要重写hashcode
因为对象里的是对象的地址对比,现在变成了属性对比,那属性里的HashSet/HashMap/Hashtable类通过hashcode找数据,则就要对比属性的哈希
(1)当obj1.equals(obj2)为true时,obj1.hashCode() == obj2.hashCode()必须为true
(2)当obj1.hashCode() == obj2.hashCode()为false时,obj1.equals(obj2)必须为false
(2)当obj1.hashCode() == obj2.hashCode()为false时,obj1.equals(obj2)必须为false
什么时候重写equal
想要判断两个对象是不是一样? 不能业务代码一个一个属性值判断吧? 重写对象的equal将属性值判断放在对象内部完成
记得先判断是不是一个类
obj instanceoof Person
hashCode() 与 equals() 之间有什么关系?
clone方法(深拷贝浅拷贝)
浅拷贝:对基本数据类型进行值传递,对引用数据类型进行引用传递般的拷贝,此为浅拷贝。
深拷贝:对基本数据类型进行值传递,对引用数据类型,创建一个新的对象,并复制其内容,此为深拷贝。
深拷贝:对基本数据类型进行值传递,对引用数据类型,创建一个新的对象,并复制其内容,此为深拷贝。
怎么实现深拷贝
序列化
重写clone
问题的产生:拷贝时如果一个对象内部引用了另一个对象,则拷贝时只传过去引用对象的地址,导致是拷贝后引用和之前的内部引用的对象是同一个对象.
即浅拷贝只是拷贝了“一层”,深拷贝是真的全部拷贝
即浅拷贝只是拷贝了“一层”,深拷贝是真的全部拷贝
对象
Java对象作为参数传递是传值还是传引用
基本类型是值传递
String也属于引用类型,String在进行传递的时候也是传递的地址,这个地址是堆中的运行时常量池中的地址,但是因为String具有不可变性,在对对象进行赋值的时候,实际上是新创建了一个对象,而不是在原有地址上进行修改,但是观感就像没有变。
引用类型是引用传递
异常
泛型
容器
Collection
HashMap和ConcurrentHashMap
红黑树
hashmap
hashmap的put方法
1、最开始是初始化;通过哈希算法与与运算(等价取模)得出数组下标
2、如果数组下标位置元素为空,则将key和value封装为Entry对象(JDK1.7中是Entry对象,JDK1.8中是Node对象)并放⼊该位置
3、如果数组下标位置元素不为空,则要分情况讨论
a. 如果是JDK1.7,则先判断是否需要扩容,如果要扩容就进⾏扩容,如果不⽤扩容就⽣成Entry对
象,并使⽤头插法添加到当前位置的链表中
b. 如果是JDK1.8,则会根据hash找出那个节点位置上的Node,先判断当前位置上的Node的类型,看是红⿊树Node,还是链表Node
i. 如果是红⿊树Node,则将key和value封装为⼀个红⿊树节点并添加到红⿊树中去,在这个过
程中会判断红⿊树中是否存在当前key,如果存在则更新value
ii. 如果此位置上的Node对象是链表节点,则将key和value封装为⼀个链表Node并通过尾插法插
⼊到链表的最后位置去,因为是尾插法,所以需要遍历链表,在遍历链表的过程中会判断是否存在当前key,如果存在则更新value,当遍历完链表后,将新链表Node插⼊到链表中,插⼊到链表后,会看当前链表的节点个数,如果⼤于等于8,且数组长度大于等于64,那么则会将该链表转成红⿊树
iii. 将key和value封装为Node插⼊到链表或红⿊树中后,再判断是否需要进⾏扩容,如果需要就扩容,如果不需要就结束PUT⽅法
2、如果数组下标位置元素为空,则将key和value封装为Entry对象(JDK1.7中是Entry对象,JDK1.8中是Node对象)并放⼊该位置
3、如果数组下标位置元素不为空,则要分情况讨论
a. 如果是JDK1.7,则先判断是否需要扩容,如果要扩容就进⾏扩容,如果不⽤扩容就⽣成Entry对
象,并使⽤头插法添加到当前位置的链表中
b. 如果是JDK1.8,则会根据hash找出那个节点位置上的Node,先判断当前位置上的Node的类型,看是红⿊树Node,还是链表Node
i. 如果是红⿊树Node,则将key和value封装为⼀个红⿊树节点并添加到红⿊树中去,在这个过
程中会判断红⿊树中是否存在当前key,如果存在则更新value
ii. 如果此位置上的Node对象是链表节点,则将key和value封装为⼀个链表Node并通过尾插法插
⼊到链表的最后位置去,因为是尾插法,所以需要遍历链表,在遍历链表的过程中会判断是否存在当前key,如果存在则更新value,当遍历完链表后,将新链表Node插⼊到链表中,插⼊到链表后,会看当前链表的节点个数,如果⼤于等于8,且数组长度大于等于64,那么则会将该链表转成红⿊树
iii. 将key和value封装为Node插⼊到链表或红⿊树中后,再判断是否需要进⾏扩容,如果需要就扩容,如果不需要就结束PUT⽅法
总结:
1、无则插入,有则分情况;
2、如果是1.7就是扩容
3、1.8因为有两种结构,会判断一下是哪种:
是链表节点就插链表是红黑树节点就插红黑树
插完再判断需不需要扩容、
1、无则插入,有则分情况;
2、如果是1.7就是扩容
3、1.8因为有两种结构,会判断一下是哪种:
是链表节点就插链表是红黑树节点就插红黑树
插完再判断需不需要扩容、
链表遍历更新
红黑树查询更新
红黑树查询更新
hashmap扩容
(1)重新建立一个新的数组,长度为原数组的两倍;
(2)遍历旧数组的每个数据,重新计算每个元素在新数组中的存储位置。使用节点的hash值与旧数组长度进行位与运算,如果运算结果为0,表示元素在新数组中的位置不变;否则,则在新数组中的位置下标=原位置+原数组长度。
(3)将旧数组上的每个数据使用尾插法逐个转移到新数组中,并重新设置扩容阈值。
(2)遍历旧数组的每个数据,重新计算每个元素在新数组中的存储位置。使用节点的hash值与旧数组长度进行位与运算,如果运算结果为0,表示元素在新数组中的位置不变;否则,则在新数组中的位置下标=原位置+原数组长度。
(3)将旧数组上的每个数据使用尾插法逐个转移到新数组中,并重新设置扩容阈值。
元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。
hashmap变树
链表数据长度大于8且数组超过64就变树
因为数组的长度较小,应该尽量避开红黑树,扩容解决
大于8进入树化函数,在判断数组是不是大于64
转换时机
链表转红黑树treeifyBin
链表长度达到8,触发转红黑树,但是会再判断数组长度是否达到64,才会转化成红黑树,否则扩容解决哈希冲突(8个是因为泊松分布)
红黑树转回链表
6个
面试题
关于哈希算法
算地址时为什么用位运算(提高效率)
前提:位运算取余必须保证table数组的size为二的幂次方
导致:所以初始时、扩容时要是的2次幂
为什么要高十六位参与hash计算(增加散列程度)
hash值32位,数组初始化size为16,直接拿hashcode取余16操作hashCode & 1111会导致很多高位不同,低位相同的数算出来的索引都是一样的,引发冲突
异或会更散一点,与和或会归0和归1
hashcode为int类型,4个字节32位,为了确保散列性,肯定是32位都能进行散列算法计算是最好的。
扩容时为什么是二倍
位运算取余必须保证table数组的size为二的幂次方
二倍保证二次幂
ConcurrentHashMap
HashMap线程不安全解析
表现
1.7表现为在多线程环境下进行扩容,由于采用头插法,位于同一索引位置的节点顺序会反掉,导致可能出现死循环的情况
1.8表现为在多线程环境下添加元素,可能会出现数据丢失的情况
怎么保证数据正确
Fail-Fast 机制
底层通过一个 modCount 值记录修改的次数,对 HashMap 的修改操作都会增加这个值。迭代器在初始过程中会将这个值赋给 exceptedModCount ,在迭代的过程中,如果发现 modCount 和 exceptedModCount 的值不一致,代表有其他线程修改了Map,就立刻抛出异常。
哪些线程安全的map可以使用
Hashtable
底层的每个方法都使用了 synchronized 保证线程同步,所以每次都锁住整张表,在性能方面会相对比较低
Collections.synchronizedMap()
获得一个synchronized同步的map
ConcurrentHashMap
ConcurrentHashMap线程安全机制
JDK1.7的--使用分段锁,segment,原理是reentrantlock。锁粒度还是不够。
segment默认16个
JDK1.8的--基于CAS和synchronized的同步代码块实现线程安全
1.CAS,发现内存位置值和原值相等,则代表没动过,可以插入
2.内存位置数据和原值不相等,需要往链表或者红黑树里插入数据则锁住当前桶
ConcurrentHashMap扩容(比hashmap快)
触发条件
数组长度达到扩容的阈值0.75
hashmap的没有final修饰,可以改
链表达到8,会进函数,在数组长度没有到64扩容
在执行putAll操作的时候,会直接优先判断是否需要扩容
tryPresize(m.size()),会尝试扩容追加的map的长度的大小
每次put
会计算扩容戳看是否需要帮助扩容
相关结论:
有的会先执行tryPresize;
有的会自行判断,计算扩容戳,执行transfer方法进行扩容
有的会先执行tryPresize;
有的会自行判断,计算扩容戳,执行transfer方法进行扩容
扩容
sizeCtl扩容戳
sizeCtl = -1 :代表当前map的数组正在初始化
在第一次 put时才会初始化数组
sizeCtl < -1 :代表当前map正在扩容,低十六位的值= -2 代表有一个线程正在扩容,高十六位是扩容标识
sizeCtl = 0 :还未初始化
sizeCtl > 0 :如果没有数组初始化代表数组长度,如果数组已经初始化,代表扩容阈值。
使用
1、扩容前先计算扩容戳,算出的扩容戳和原数组的长度是绑定的,比如同是长度16,扩容戳相同
相同则帮助其扩容
不同则回头自己扩
不同则回头自己扩
2、算出来的rs,则真正的扩容戳是(rs<<16)+2,并将扩容戳赋值给sizeCtl
扩容戳是个负数
10000000 00011010 00000000 00000010
高十六位:扩容的标识(32->64)
低十六位:正在扩容的线程个数(此例是-2,代表有一个线程在扩容)
都是32->64或者64->128,多线程扩容时,要得是同比例扩大(2倍)。
10000000 00011010 00000000 00000010
高十六位:扩容的标识(32->64)
低十六位:正在扩容的线程个数(此例是-2,代表有一个线程在扩容)
都是32->64或者64->128,多线程扩容时,要得是同比例扩大(2倍)。
刚扩容时候先根据原长度算出扩容后的大小,再算出正在扩容的线程数+2
流程
1、开始扩容,线程只有一个,会把新数组new出来
2、其他线程会从transfer\helpTransfer方法进来,进来时候会对扩容戳进行CAS操作加一
3、一个桶一个桶的迁移,迁移完会把ForwardingNode设置到老数据,哈希设置成-1,代表已经迁移了,可用find函数找
4、链表迁移是使用lastRun机制
2、其他线程会从transfer\helpTransfer方法进来,进来时候会对扩容戳进行CAS操作加一
3、一个桶一个桶的迁移,迁移完会把ForwardingNode设置到老数据,哈希设置成-1,代表已经迁移了,可用find函数找
4、链表迁移是使用lastRun机制
哈希值的含义,一定是个正数
如果 Node.hash = -1,表示当前节点是 **FWD(ForWardingNode) **节点(表示已经被迁移的节点)。
如果 Node.hash = -2,表示当前节点已经树化,且当前节点为 TreeBin 对象,TreeBin 对象代理操作红黑树。
如果 Node.hash > 0,表示当前节点是正常的 Node 节点,可能是链表,或者单个 Node。
如果 Node.hash = -2,表示当前节点已经树化,且当前节点为 TreeBin 对象,TreeBin 对象代理操作红黑树。
如果 Node.hash > 0,表示当前节点是正常的 Node 节点,可能是链表,或者单个 Node。
,那么就可以通过 FWD 节点的 find() 方法重新定向到新散链表中去查询目标元素!
put
首先,先判断散链表是否已经初始化,如果没初始化则先初始化散链表,再进行写入操作。
② 当向桶位中写数据时,先判断桶中是否为空,如果是空桶,则直接通过 CAS 的方式将新增数据节点写入桶中。
并且计算扩容戳看看是不是需要帮助扩容
如果当前桶中不为空,就需要判断当前桶中头结点的类型(哈希是不是-2,哈希-2代表树节点)
③ 如果桶中头结点的 hash 值 为 -1,表示当前桶位的头结点为 FWD 结点,目前散链表正处于扩容过程中。这时候当前线程需要去协助扩容。
④ 如果 ②、③ 条件不满足,则表示当前桶位的存放的可能是一条链表,也可能是红黑树的代理对象 TreeBin。这种情况下会使用 synchronized 锁住桶中的头结点,来保证桶内的写操作是线程安全的。
② 当向桶位中写数据时,先判断桶中是否为空,如果是空桶,则直接通过 CAS 的方式将新增数据节点写入桶中。
并且计算扩容戳看看是不是需要帮助扩容
如果当前桶中不为空,就需要判断当前桶中头结点的类型(哈希是不是-2,哈希-2代表树节点)
③ 如果桶中头结点的 hash 值 为 -1,表示当前桶位的头结点为 FWD 结点,目前散链表正处于扩容过程中。这时候当前线程需要去协助扩容。
④ 如果 ②、③ 条件不满足,则表示当前桶位的存放的可能是一条链表,也可能是红黑树的代理对象 TreeBin。这种情况下会使用 synchronized 锁住桶中的头结点,来保证桶内的写操作是线程安全的。
当桶位中链表升级为红黑树,且当前红黑树上有读线程正在访问,那么如果再来新的写线程请求该怎么处理?
写线程会被阻塞,因为红黑树比较特殊,新写入数据,可能会触发红黑树的自平衡,这就会导致树的结构发生变化,会影响读线程的读取结果!
在红黑树上读取数据和写入数据是互斥的,具体原理分析如下:
我们知道 ConcurrentHashMap 中的红黑树由 TreeBin 来代理,TreeBin 内部有一个 Int 类型的 state 字段。
当读线程在读取数据时,会使用 CAS 的方式将 state 值 +4(表示加了读锁),读取数据完毕后,再使用CAS 的方式将 state 值 -4。
如果写线程去向红黑树中写入数据时,会先检查 state 值是否等于 0,如果是 0,则说明没有读线程在检索数据,这时候可以直接写入数据,写线程也会通过 CAS 的方式将 state 字段值设置为 1(表示加了写锁)。
如果写线程检查 state 值不是 0,这时候就会park()挂起当前线程,使其等待被唤醒。挂起写线程时,写线程会先将 state 值的第 2 个 bit 位设置为 1(二进制为 10),转换成十进制就是 2,表示有写线程等待被唤醒。
反过来,当红黑树上有写线程正在执行写入操作,那么如果有新的读线程请求该怎么处理?
在红黑树上读取数据和写入数据是互斥的,具体原理分析如下:
我们知道 ConcurrentHashMap 中的红黑树由 TreeBin 来代理,TreeBin 内部有一个 Int 类型的 state 字段。
当读线程在读取数据时,会使用 CAS 的方式将 state 值 +4(表示加了读锁),读取数据完毕后,再使用CAS 的方式将 state 值 -4。
如果写线程去向红黑树中写入数据时,会先检查 state 值是否等于 0,如果是 0,则说明没有读线程在检索数据,这时候可以直接写入数据,写线程也会通过 CAS 的方式将 state 字段值设置为 1(表示加了写锁)。
如果写线程检查 state 值不是 0,这时候就会park()挂起当前线程,使其等待被唤醒。挂起写线程时,写线程会先将 state 值的第 2 个 bit 位设置为 1(二进制为 10),转换成十进制就是 2,表示有写线程等待被唤醒。
反过来,当红黑树上有写线程正在执行写入操作,那么如果有新的读线程请求该怎么处理?
并发编程
思路
为什么学习线程?为了解决CPU利用率问题,提高CPU利用率。
=》 什么是进程?什么是线程?
=》 怎么创建线程?有哪几种方式?有什么特点?
=》 分别怎么启动线程?
=》 多线程带来了数据安全问题,该怎么解决?
=》 怎么使用synchronized(同步)决解?
=》使用同步可能会产生死锁,该怎么决解?
=》 线程之间是如何通信的?
=》 线程有返回值吗?该如何拿到?
=》 怎么才能一次性启动几百上千个的线程?
=》 什么是进程?什么是线程?
=》 怎么创建线程?有哪几种方式?有什么特点?
=》 分别怎么启动线程?
=》 多线程带来了数据安全问题,该怎么解决?
=》 怎么使用synchronized(同步)决解?
=》使用同步可能会产生死锁,该怎么决解?
=》 线程之间是如何通信的?
=》 线程有返回值吗?该如何拿到?
=》 怎么才能一次性启动几百上千个的线程?
基础知识
计算机基础概念
进程与线程
进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用
程序运行的载体。进程是一种抽象的概念,从来没有统一的标准定义。
程序运行的载体。进程是一种抽象的概念,从来没有统一的标准定义。
而进程由内存空间(代码,数据,进程空间,打开的文件)和一个或多个线程组成。
线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,是处理器调度和分派的基本单位。一个进程可以有一个或
多个线程,各个线程之间共享程序的内存空间(也就是所在进程的内存空间)。
多个线程,各个线程之间共享程序的内存空间(也就是所在进程的内存空间)。
一个标准的线程由线程ID,当前指令指针PC,寄存器和堆栈组成。
CPU核心数,线程数
CPU数:独立的中央处理单元,体现在主板上就是有多少个CPU槽位
CPU核心数:在每一个CPU上,都可能有多核(core),每个核中都有独立的ALU,FPU,Cache等组件,可以理解为CPU的物理核数。(我们常说4核8线程中的核),指物理上存在的物体。
CPU线程数(processor逻辑核)
一种逻辑上的概念,并非真实存在的物体,只是为了更好地描述CPU的运作能力。简单地说,就是模拟出的CPU核心数。
每一个物理核可以模拟出多个逻辑核,"超线程"技术就是通过采用特殊的指令,把逻辑内核模拟为物理超线程,这样的核就是processor.是一个处理数据的通道,流水线。可以理解为逻辑核(比如我们常说的4核8线程中的线程)
CPU时间片轮转机制——上下文切换
CPU在执行多个线程的时候,会不断的切换执行的任务。这个任务之间的切换动作我们就叫做上下文切换。
什么是并行和并发
并发:指应用能够交替执行不同的任务,其实并发有点类似于多线程的原理,多线程并非是同时执行多个任务,如果你开两个线程执行,就是在你几乎不可能察觉到的速度不断去切换这两个任务,以达到"同时执行效果",其实并不是的,只是计算机的速度太快,我们无法察觉到而已。
并行:指应用能够同时执行不同的任务,例:吃饭的时候可以边吃饭边打电话,这两件事情可以同时执行。
并发编程基础概念
内存模型JMM
JMM结构规范
JMM规定了所有的变量都存储在主内存(Main Memory)中。每个线程还有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。
三个特征
原子性(Atomicity)
一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
1、基本类型的读取和赋值操作,且赋值必须是值赋给变量,变量之间的相互赋值不是原子性操作;
2、所有引用reference的赋值操作;
3、java.concurrent.Atomic.* 包中所有类的一切操作。
2、所有引用reference的赋值操作;
3、java.concurrent.Atomic.* 包中所有类的一切操作。
由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write。大致可以认为
1、基本数据类型的操作是原子性的。
2、同时lock和unlock可以保证更大范围操作的原子性。
3、 synchronize同步块操作的原子性是用更高层次的字节码指令monitorenter和monitorexit来隐式操作的。
1、基本数据类型的操作是原子性的。
2、同时lock和unlock可以保证更大范围操作的原子性。
3、 synchronize同步块操作的原子性是用更高层次的字节码指令monitorenter和monitorexit来隐式操作的。
CAS和锁来保证
可见性(Visibility)
指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
1、volatile来保证可见性,当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。
2、synchronize和Lock都可以保证可见性。synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
2、synchronize和Lock都可以保证可见性。synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
JAVA内存模型来保证
有序性(Ordering)
即程序执行的顺序按照代码的先后顺序执行。
如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。
as-if-searil、happens-before来保证
主内存和本地内存结构
概念
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。本地内存它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化之后的一个数据存放位置
原子性变量操作包括read、load、assign、use、store和write
作用
JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。
或者说
内存模型JMM控制多线程对共享变量的可见性!!!
或者说
内存模型JMM控制多线程对共享变量的可见性!!!
本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。
重排序
概念
重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段
三种重排序
JAVA源代码--->编译器优化的重排序--->指令级并行的重排序--->内存系统的重排序--->最终执行指令
编译器优化的重排序
编译器在不改变单线程语义下,可以重新安排语句的执行顺序
指令级并行的重排序
处理器将多条指令重叠执行
内存系统的重排序
处理器使用缓存和读写缓冲区,是的加载和存储操作看上去可能是在乱序执行
引发的问题
可见性问题
有序性问题
解决方法
编译器的重排序
JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。
处理器的重排序
JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,来禁止特定类型的处理器重排序。
JMM根据代码中的关键字(如:synchronized、volatile)和J.U.C包下的一些具体类来插入内存屏障。
规则
数据依赖性
存在数据依赖关系的两个语句,不可以重排序
如果两个操作同时操作同一个变量且其中一个操作是写操作,则这两个操作之间存在数据依赖
① 写后读:a = 1;b = a; 写一个变量之后,再读这个位置
② 写后写:a = 1;a = 2; 写一个变量之后,再写这个变量。
③ 读后写:a = b;b = 1;读一个变量之后,再写这个变量。
as-if-serial原则
看起来好像是串行的
不管怎么重排序,单线程语义下程序的执行结果不能变
遵守数据依赖性和as-if-serial 语义实质上是一回事。为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。
happens-before原则
在同一个线程或者多个线程之间,如果A线程的a操作 happends-before B线程的b操作,那么JMM向程序员保证a操作对b操作是可见的。
1、如果a操作happends-before 另一个b操作,那么a操作需要对b操作是可见的,并且a操作发生在b操作之前
2、JMM只是保证了happends-before语义的正确性,即使内部发生了指令重排序,那么我们也不用管,只要满足第一条,我们就认为他是符合happends-before语义的。换句话说,JMM这种优化就是合法的。
2、JMM只是保证了happends-before语义的正确性,即使内部发生了指令重排序,那么我们也不用管,只要满足第一条,我们就认为他是符合happends-before语义的。换句话说,JMM这种优化就是合法的。
as-if-serial原则和happens-before原则的区别
as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。
happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
一句话:都是为了保证重排序对最终结果不会产生影响
内存屏障
为了保证内存可见性,可以通过volatile、final等修饰变量,java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。
功能
阻止屏障两侧的指令重排序;
它会强制将对缓存的修改操作立即写入主存
如果是写操作,它会导致其他CPU中对应的缓存行无效缓存一致性(窥探技术 + MESI协议 ))
种类
硬件层面的内存屏障
Load Barrier 和 Store Barrier即读屏障和写屏障。
对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存加载数据;
对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见
java内存屏障
LoadLoad
简单的理解就是当有两个Load,一个Load1一个Load2,Load1加载代码要从内存里面读取的数据读取完毕之后,Load2加载代码才能读取数据。
StoreStore
理解为当有两个Store,一个是Store1一个是Store2,Store1的写入操作已经把数据写入到内存里面,并且保证Store1的写入操作对其它处理器可见之后,才会对Store2存储代码进行写入操作执行。
LoadStore
理解为当有一个Load1和一个Store2,要先保证Load1加载代码要从内存里面读取的数据读取完毕之后,Store2存储代码才会进行写入操作。
StoreLoad
理解为当有一个Store1和Load2,要先保证Store1的写入操作已经把数据写入到内存里面,并且确认Store1的写入操作对其它处理器可见,Load2加载代码才从内存里面读取数据。而且因StoreLoad Barriers同时具备其他三个屏障的效果,因此也称之为全能屏障,是目前大多数处理器所支持的,但是相对其他屏障,该屏障的开销相对昂贵的。
内存屏障在Volatile关键字里面的作用
在每个volatile写操作前插入StoreStore屏障,这样就能让其他线程修改A变量后,把修改的值对当前线程可见,在写操作后插入StoreLoad屏障,这样就能让其他线程获取A变量的时候,能够获取到已经被当前线程修改的值
在每个volatile读操作前插入LoadLoad屏障,这样就能让当前线程获取A变量的时候,保证其他线程也都能获取到相同的值,这样所有的线程读取的数据就一样了,在读操作后插入LoadStore屏障;这样就能让当前线程在其他线程修改A变量的值之前,获取到主内存里面A变量的的值。
JAVA多线程基础概念
线程基本状态
创建线程方式
创建线程的方法
继承Thread类
实现Runnable接口
实现Callable接口
使用线程池创建
并发实现基础知识
AQS
CAS
可重入性
不可中断性
线程同步
synchronized
基本信息
特性保证
有序性
as-if-serial
happens-before
可见性
因为某一个线程进入synchronized代码块前后,线程会获得锁,清空工作内存,从主内存拷贝共享变量最新的值到工作内存成为副本,执行代码,将修改后的副本的值刷新回主内存中,线程释放锁。而获取不到锁的线程会阻塞等待,所以变量的值肯定一直都是最新的。
原子性
可重入性
不可中断性
原理
monitor的对象来完成
其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。
同步代码块时
当我们进入一个人方法的时候,执行monitorenter,就会获取当前对象的一个所有权,这个时候monitor进入数为1,当前的这个线程就是这个monitor的owner。
如果你已经是这个monitor的owner了,你再次进入,就会把进入数+1.
同理,当他执行完monitorexit,对应的进入数就-1,直到为0,才可以被其他线程持有。
如果你已经是这个monitor的owner了,你再次进入,就会把进入数+1.
同理,当他执行完monitorexit,对应的进入数就-1,直到为0,才可以被其他线程持有。
同步方法时
一个特殊标志位,ACC_SYNCHRONIZED。
同步方法的时候,一旦执行到这个方法,就会先判断是否有标志位,然后,ACC_SYNCHRONIZED会去隐式调用刚才的两个指令:monitorenter和monitorexit。
所以归根究底,还是monitor对象的争夺。
同步方法的时候,一旦执行到这个方法,就会先判断是否有标志位,然后,ACC_SYNCHRONIZED会去隐式调用刚才的两个指令:monitorenter和monitorexit。
所以归根究底,还是monitor对象的争夺。
使用场景
修饰实例方法(不包括静态方法) 锁实例对象
一个类,开两个线程可以同步
每个对象都有一个对象锁,不同的对象,他们的锁不会互相影响。
new两个对象就不行了,存在着两个不同的实例对象锁
new两个对象就不行了,存在着两个不同的实例对象锁
修饰实例方法代码块
synchronized(this) 锁实例对象
一个类,开两个线程可以同步
synchronized(类.class) 锁类对象
所有对象可同步
修饰静态方法 以及 静态方法中的代码块 锁类对象
所有对象可同步
注意:在java中,实际上同步加锁的是一个对象或者一个类,而不是代码。
当前类class锁被获取,不影响对象锁的获取,两者互不影响。
我们应该意识到这种情况下可能会发现线程安全问题(操作了共享静态变量i)。
即类锁代码访问了i,对象锁代码也访问了i.
即类锁代码访问了i,对象锁代码也访问了i.
实现机制
monitorenter
monitorexit
程序计数器count
加减
JVM对象
对象头(Header)
MARK WORD(标记字段)
默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着标志位的变化而变化。
CLASS POINT (类型指针)
对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
Monitor
EntryList
Owner(会指向持有Monitor对象的线程)
WaitSet
实例数据
存放类的数据信息,父类的信息
对齐填充
由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。
一个空对象占多少个字节?就是8个字节,是因为对齐填充的关系哈,不到8个字节对其填充会帮我们自动补齐。
锁优化
从JDK5引入了现代操作系统新增加的CAS原子操作( JDK5中并没有对synchronized关键字做优化,而是体现在J.U.C中,所以在该版本concurrent包有更好的性能 ),从JDK6开始,就对synchronized的实现机制进行了较大调整,包括使用JDK5引进的CAS自旋之外,还增加了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略。由于此关键字的优化使得性能极大提高.
重锁
内核态和用户态之间的转换
大概流程
用户态把一些数据放到寄存器,或者创建对应的堆栈,表明需要操作系统提供的服务。
用户态执行系统调用(系统调用是操作系统的最小功能单位)。
CPU切换到内核态,跳到对应的内存指定的位置执行指令。
系统调用处理器去读取我们先前放到内存的数据参数,执行程序的请求。
调用完成,操作系统重置CPU为用户态返回结果,并执行下个指令。
用户态执行系统调用(系统调用是操作系统的最小功能单位)。
CPU切换到内核态,跳到对应的内存指定的位置执行指令。
系统调用处理器去读取我们先前放到内存的数据参数,执行程序的请求。
调用完成,操作系统重置CPU为用户态返回结果,并执行下个指令。
1.6之前是重量级锁
1.6之后优化了
ReentrantLock
ReentrantLock锁是一个轻量级锁,底层其实就是用自旋锁也叫无锁(CAS)实现的,lock锁不依赖操作系统,而是使用java实现的锁,当我们调用lock方法的时候,在内部其实调用了Sync.lock()方法,而Sync继承了AbstractQueuedSynchronizer,简称AQS,所以在底层调用的其实是AQS的 lock() 方法;
ReentrantLock是基于AQS框架实现的锁,它类似于Synchronized互斥锁,可以保证线程安全。
ReentrantLock相比Synchronized,拥有更多的特性,比如支持手动加锁、解锁,支持公平锁等。
ReentrantLock相比Synchronized,拥有更多的特性,比如支持手动加锁、解锁,支持公平锁等。
AQS
概念
AQS定义了一套多线程访问共享资源的同步器框架,是一个依赖状态的同步器。AQS定义了很多并发中的行为,比如:
阻塞等待队列、共享/独占、公平/非公平、可重入、允许中断
阻塞等待队列、共享/独占、公平/非公平、可重入、允许中断
缺点
由于AQS维护可一个state,所以上锁和解锁要次数一样,不能用于递归
unsafe下面的cas方法
455
锁相关
锁的分类
乐观锁&悲观锁
概述
根据对同步资源处理策略不同,锁在宏观上分为乐观锁与悲观锁,这只是概念上的一种称呼,Java中并没有具体的实现类叫做乐观锁或者悲观锁。
概念
乐观锁
认为别人不会修改,所以不会上锁,但是在执行更新时会问一下(判断):
没有更新,则当前线程将自己的值才成功写入;
已经被更新,则根据不同的实现方式执行不同的操作,例如报错或者重试。
没有更新,则当前线程将自己的值才成功写入;
已经被更新,则根据不同的实现方式执行不同的操作,例如报错或者重试。
乐观锁回滚重试
悲观锁
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁
悲观锁阻塞事务
实现方式
乐观锁
在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。
悲观锁
Java的synchronized和ReentrantLock等锁去实现
适用场景
乐观锁
适合用于写操作比较少的场景,因为冲突很少发生,这样可以省去锁的开销,加大了系统的整个吞吐量。
悲观锁
适用于写操作比较多的场景。如果经常产生冲突,上层应用会不断的进行重试,乐观锁反而会降低性能,悲观锁则更加合适。
乐观锁之CAS
概念
CAS,即Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。
在多线程环境下尽量不要用i++,要用getAndIncrement()
理解
比较:读取到一个值 A,在将其更新为 B 之前,检查原值是否为 A(未被其它线程修改过,这里忽略 ABA 问题)。
替换:如果是,更新 A 为 B,结束。如果不是,则不会更新。
替换:如果是,更新 A 为 B,结束。如果不是,则不会更新。
存在的问题
ABA问题
ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
循环时间长开销大
CAS操作如果长时间不成功,do while会导致其一直自旋,给CPU带来非常大的开销。
只能保证一个共享变量的原子操作
对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
自旋锁 & 适应性自旋锁
自旋锁
来历
线程状态切换的开销是非常大的
线程状态切换会使CPU运行状态从用户态转换到内核态。
每个线程在运行时的指令是被放在CPU的寄存器中的,如果切换内存状态,需要先把本线程的代码和变量写入内存,这样经常切换会耗费时间。
每个线程在运行时的指令是被放在CPU的寄存器中的,如果切换内存状态,需要先把本线程的代码和变量写入内存,这样经常切换会耗费时间。
解释
线程因为无法获取到锁而进行挂起以及后续的恢复操作,这个时间消耗很可能大于同步资源的锁定时间,这种情况对系统而言是得不偿失的。
那么,能不能让获取锁失败的线程先不挂起,而是“稍等一下”,如果锁被释放,这个线程便可以直接获取锁,从而避免线程切换。这个“稍等一下”依赖于自旋实现,所谓自旋,即在一个循环中不停判断是否可以获取锁。获取锁的操作,就是通过 CAS 操作修改对象头里的锁标志位。先比较当前锁标志位是否为释放状态,如果是,将其设置为锁定状态,比较并设置是原子性操作,这个是 JVM 层面保证的。当前线程就算持有了锁,然后线程将当前锁的持有者信息改为自己。
这是一种折中的思想,用短时间的忙等来换取线程切换的开销。
它并不会放弃 CPU 时间片,而是通过自旋等待锁的释放,也就是说,它会不停地再次地尝试获取锁,如果失败就再次尝试,直到成功为止
那么,能不能让获取锁失败的线程先不挂起,而是“稍等一下”,如果锁被释放,这个线程便可以直接获取锁,从而避免线程切换。这个“稍等一下”依赖于自旋实现,所谓自旋,即在一个循环中不停判断是否可以获取锁。获取锁的操作,就是通过 CAS 操作修改对象头里的锁标志位。先比较当前锁标志位是否为释放状态,如果是,将其设置为锁定状态,比较并设置是原子性操作,这个是 JVM 层面保证的。当前线程就算持有了锁,然后线程将当前锁的持有者信息改为自己。
这是一种折中的思想,用短时间的忙等来换取线程切换的开销。
它并不会放弃 CPU 时间片,而是通过自旋等待锁的释放,也就是说,它会不停地再次地尝试获取锁,如果失败就再次尝试,直到成功为止
好处
自旋锁用循环去不停地尝试获取锁,让线程始终处于 Runnable 状态,节省了线程状态切换带来的开销
坏处
长时间的自旋带来的开销可能会大于线程的切换
是一种非公平锁,等待时间最长的线程并不能优先获取锁,可能会产生“线程饥饿”问题。
自旋获取锁过程完全看运气
适应性自旋锁
来历
由自旋锁的坏处可知,自旋应该是有限度的。
在门口等着消息进门,但是旋(短时间等)不出名堂,第一个人回家等消息,然后大家都不旋了,都回家等消息。
解释
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
上一个自选获得锁成功,且成功运行,则下一次可能很快就成功,后续自旋等待时间会更长些
自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
适用场景
在 Java 1.5 版本及以上的并发包中,也就是 java.util.concurrent 的包中,里面的原子类基本都是自旋锁的实现。外加AQS。
getAndIncrement
getAndIncrement 也是直接调用 nsafe 的 getAndAddInt 方法,从下面源码可以看出这个方法直接就是做了一个 do-while 的循环。「这个循环就是一个自旋操作,如果在修改过程中遇到了其他线程竞争导致没修改成功的情况,就会 while 循环里进行死循环,直到修改成功为止」。
getAndIncrement 也是直接调用 nsafe 的 getAndAddInt 方法,从下面源码可以看出这个方法直接就是做了一个 do-while 的循环。「这个循环就是一个自旋操作,如果在修改过程中遇到了其他线程竞争导致没修改成功的情况,就会 while 循环里进行死循环,直到修改成功为止」。
并发不高,避免一直自旋不成功
代码简单,不能太复杂,耗时比较短
代码简单,不能太复杂,耗时比较短
自旋锁适用于并发度不是特别高的场景,以及临界区比较短小的情况,这样我们可以利用避免线程切换来提高效率
可是如果临界区很大,线程一旦拿到锁,很久才会释放的话,那就不合适用自旋锁,因为自旋会一直占用 CPU 却无法拿到锁,白白消耗资源
可是如果临界区很大,线程一旦拿到锁,很久才会释放的话,那就不合适用自旋锁,因为自旋会一直占用 CPU 却无法拿到锁,白白消耗资源
手写自旋锁
使用 AtomicReference 类提供一个可以原子读写的对象引用变量。
定义一个加锁方法,如果有其他线程已经获取锁,当前线程将进入自旋,如果还是已经持有锁的线程获取锁,那就是重入。
定义一个解锁方法,解锁的话,只有持有锁的线程才能解锁,解锁的逻辑思维将 count-1,如果 count == 0,则是把当前持有锁线程设置为 null,彻底释放锁。
公平锁 & 非公平锁
概念
公平锁
公平锁是指,如果多个线程争夺一把公平锁,这些线程会进入一个按申请顺序排序的队列(队列中的线程都处于阻塞状态,等待唤醒),当锁释放的时候,队列中第一个线程获取锁。等待的线程挨个获得锁。
非公平锁
非公平锁机制下,试图获取锁的线程们会尝试直接去获取锁,获取不到再进入等待队列。
ReentrantLock 公平锁和非公平锁的实现机制
实现
ReentrantLock类无参构造函数指定了使用非公平锁 NonfairSync,
另外又提供了有参构造方法,允许调用者自己指定使用公平锁 FairSync 还是非公平锁 NonfairSync
(FairSync和NonfairSync是ReentrantLock 内部类 Sync 的两个子类,而添加锁和释放锁的大部分操作是 Sync 实现的)。
另外又提供了有参构造方法,允许调用者自己指定使用公平锁 FairSync 还是非公平锁 NonfairSync
(FairSync和NonfairSync是ReentrantLock 内部类 Sync 的两个子类,而添加锁和释放锁的大部分操作是 Sync 实现的)。
对比
平锁相对于非公平锁多了一个限制条件:hasQueuedPredecessors(),这个方法是判断当前线程是否位于队列的首位
hasQueuedPredecessors()
公平锁与非公平锁的优劣势比较
非公平锁通过插队直接获取锁,减少了一次线程阻塞与唤醒过程,系统整体吞吐量提升。
公平锁不会线程饥饿,非公平锁不能保证等待时间最长的线程优先获取锁。
线程饥饿
具体
synchronized 锁只能是一种非公平锁;
ReentrantLock 锁则可以通过构造函数指定使用公平锁还是非公平锁(默认是非公平锁)。
ReentrantLock 锁则可以通过构造函数指定使用公平锁还是非公平锁(默认是非公平锁)。
可重入锁 & 不可重入锁
概念
可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。
不可重入锁
反之
实现机制
ReentrantLock和NonReentrantLock都继承AQS,AQS中维护了一个同步状态 state 来计数,state 初始值为0,随着占有锁的线程的子流程占据和释放锁,state进行相应增减操作。getState() 方法能获取最新的 state 值。即通过state的累加和最终状态是否是0来实现可重入。
获取锁时
可重入锁先查询当前 state 值,如果status 是 0,表示没有其他线程在占有锁,则该线程获取锁并将 state 执行+1操作。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行state+1操作,如此循环,当前线程便可以重复获得锁。
非可重入锁是直接去判断当前 state 的值是否是 0 ,如果是则将其置为1,并返回 true,从而获取锁,如果不是 0 ,则返回 false,获取锁失败,当前线程阻塞。
非可重入锁是直接去判断当前 state 的值是否是 0 ,如果是则将其置为1,并返回 true,从而获取锁,如果不是 0 ,则返回 false,获取锁失败,当前线程阻塞。
释放锁时
可重入锁先获取当前 state 的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。
非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。
非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。
非重入锁容易导致死锁问题
代码递归时持有同一个对象锁,但是第一次递归的代码持有锁了,第二次递归就拿不到,则死锁。
具体
Java 中以 Reentrant 开头命名的锁都是可重入锁,而且 JDK 提供的所有现成 Lock 的实现类,包括 synchronized 关键字锁都是可重入的。不可重入锁需要自己去实现。不可重入锁的使用场景非常非常少。
共享锁 & 独享锁 & 读写锁
概念
共享锁 & 独享锁
共享锁是指该锁可被多个线程所持有。
独享锁,也叫排他锁,是指该锁一次只能被一个线程所持有。
共享锁与独享锁互斥,
独享锁与独享锁互斥。
独享锁,也叫排他锁,是指该锁一次只能被一个线程所持有。
共享锁与独享锁互斥,
独享锁与独享锁互斥。
Synchronized和ReentrantLock就是一种排它锁,
CountDownLatch是一种共享锁,它们都是纯粹的共享锁或独享锁,不能转换形态。
CountDownLatch是一种共享锁,它们都是纯粹的共享锁或独享锁,不能转换形态。
读写锁
ReentrantReadWriteLock(读写锁)就是一种特殊的锁,它既是互斥锁,又是共享锁,read模式是共享,write是互斥(排它锁)的
写锁
这段代码首先取到当前锁的个数c,然后再通过c来获取写锁的个数w。因为写锁是低16位,所以取低16位的最大值与当前的c做与运算( int w = exclusiveCount©; ),高16位和0与运算后是0,剩下的就是低位运算的值,同时也是持有写锁的线程数目。
在取到写锁线程的数目后,首先判断是否已经有线程持有了锁。如果已经有线程持有了锁(c!=0),则查看当前写锁线程的数目,如果写线程数为0(即此时存在读锁)或者持有锁的线程不是当前线程就返回失败(涉及到公平锁和非公平锁的实现)。
如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
如果当且写线程数为0(那么读线程也应该为0,因为上面已经处理c!=0的情况),并且当前线程需要阻塞那么就返回失败;如果通过CAS增加写线程数失败也返回失败。
如果c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者,返回成功!
在取到写锁线程的数目后,首先判断是否已经有线程持有了锁。如果已经有线程持有了锁(c!=0),则查看当前写锁线程的数目,如果写线程数为0(即此时存在读锁)或者持有锁的线程不是当前线程就返回失败(涉及到公平锁和非公平锁的实现)。
如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
如果当且写线程数为0(那么读线程也应该为0,因为上面已经处理c!=0的情况),并且当前线程需要阻塞那么就返回失败;如果通过CAS增加写线程数失败也返回失败。
如果c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者,返回成功!
读锁
可以看到在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁。读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是“1<<16”。所以读写锁才能实现读读的过程共享,而读写、写读、写写的过程互斥。
锁的升级Synchronized
偏向锁
向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁
线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
轻量级锁
轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋(关于自旋的介绍见文末)的形式尝试获取锁,线程不会阻塞,从而提高性能。
轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。不会进入阻塞队列中
缺点
长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。
何时升级
此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)
重量级锁
重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。
当锁膨胀(inflate)为重量锁时,就不能再退回到轻量级锁。
因为已经到编译了
其他锁的优化
锁消除
锁消除即删除不必要的加锁操作。虚拟机即时编辑器在运行时,对一些“代码上要求同步,但是被检测到不可能存在共享数据竞争”的锁进行消除。
根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。
根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。
虽然StringBuffer的append是一个同步方法,但是这段程序中的StringBuffer属于一个局部变量,并且不会从该方法中逃逸出去(即StringBuffer sb的引用没有传递到该方法外,不可能被其他线程拿到该引用),所以其实这过程是线程安全的,可以将锁消除。
锁粗化
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
如果虚拟机检测到有一串零碎的操作都是对同一对象的加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
如果虚拟机检测到有一串零碎的操作都是对同一对象的加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。
线程池
优势
降低资源消耗
线程池通常会维护一些线程(数量为 corePoolSize),这些线程被重复使用来执行不同的任务,任务完成后不会销毁。
提高响应速度
由于线程池维护了一批 alive 状态的线程,当任务到达时,不需要再创建线程,而是直接去执行任务,从而减少了任务的等待时间。
提高线程的可管理性
使用线程池可以对线程进行统一的分配,调优和监控。
设计思路
工厂中有固定的一批工人,称为正式工人,工厂接收的订单由这些工人去完成。
当订单增加,正式工人已经忙不过来了,工厂会将生产原料暂时堆积在仓库中,等有空闲的工人时再处理(因为工人空闲了也不会主动处理仓库中的生产任务,所以需要调度员实时调度)。
仓库堆积满了后,订单还在增加怎么办?工厂只能临时扩招一批工人来应对生产高峰,而这批工人高峰结束后是要清退的,所以称为临时工。
当时临时工也招满后(受限于工位限制,临时工数量有上限),后面的订单只能忍痛拒绝了。我们做如下一番映射:
工厂——线程池
订单——任务(Runnable)
正式工人——核心线程
临时工——普通线程
仓库——任务队列
调度员——getTask()
当订单增加,正式工人已经忙不过来了,工厂会将生产原料暂时堆积在仓库中,等有空闲的工人时再处理(因为工人空闲了也不会主动处理仓库中的生产任务,所以需要调度员实时调度)。
仓库堆积满了后,订单还在增加怎么办?工厂只能临时扩招一批工人来应对生产高峰,而这批工人高峰结束后是要清退的,所以称为临时工。
当时临时工也招满后(受限于工位限制,临时工数量有上限),后面的订单只能忍痛拒绝了。我们做如下一番映射:
工厂——线程池
订单——任务(Runnable)
正式工人——核心线程
临时工——普通线程
仓库——任务队列
调度员——getTask()
深入线程池
实现
ThreadPoolExecutor:从Java线程池Executor框架体系可以看出:线程池的真正实现类是ThreadPoolExecutor,因此我们接下来重点研究这个类。
ThreadPoolExecutor的构造方法
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), defaultHandler);
}
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
threadFactory, defaultHandler);
}
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
RejectedExecutionHandler handler) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), handler);
}
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), defaultHandler);
}
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
threadFactory, defaultHandler);
}
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
RejectedExecutionHandler handler) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), handler);
}
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}
参数解释
corePoolSize(必需):核心线程数。即池中一直保持存活的线程数,即使这些线程处于空闲。
但是将allowCoreThreadTimeOut参数设置为true后,核心线程处于空闲一段时间以上,也会被回收。
maximumPoolSize(必需):池中允许的最大线程数。当核心线程全部繁忙且任务队列打满之后,线程池会临时追加线程,直到总线程数达到maximumPoolSize这个上限。
keepAliveTime(必需):线程空闲超时时间。当非核心线程处于空闲状态的时间超过这个时间后,该线程将被回收。
将allowCoreThreadTimeOut参数设置为true后,核心线程也会被回收。
unit(必需):keepAliveTime参数的时间单位。有:TimeUnit.DAYS(天)、TimeUnit.HOURS(小时)、TimeUnit.MINUTES(分钟)、TimeUnit.SECONDS(秒)、TimeUnit.MILLISECONDS(毫秒)、TimeUnit.MICROSECONDS(微秒)、TimeUnit.NANOSECONDS(纳秒)
workQueue(必需):任务队列,采用阻塞队列实现。当核心线程全部繁忙时,后续由execute方法提交的Runnable将存放在任务队列中,等待被线程处理。
threadFactory(可选):线程工厂。指定线程池创建线程的方式。
handler(可选):拒绝策略。当线程池中线程数达到maximumPoolSize且workQueue打满时,后续提交的任务将被拒绝,handler可以指定用什么方式拒绝任务。
但是将allowCoreThreadTimeOut参数设置为true后,核心线程处于空闲一段时间以上,也会被回收。
maximumPoolSize(必需):池中允许的最大线程数。当核心线程全部繁忙且任务队列打满之后,线程池会临时追加线程,直到总线程数达到maximumPoolSize这个上限。
keepAliveTime(必需):线程空闲超时时间。当非核心线程处于空闲状态的时间超过这个时间后,该线程将被回收。
将allowCoreThreadTimeOut参数设置为true后,核心线程也会被回收。
unit(必需):keepAliveTime参数的时间单位。有:TimeUnit.DAYS(天)、TimeUnit.HOURS(小时)、TimeUnit.MINUTES(分钟)、TimeUnit.SECONDS(秒)、TimeUnit.MILLISECONDS(毫秒)、TimeUnit.MICROSECONDS(微秒)、TimeUnit.NANOSECONDS(纳秒)
workQueue(必需):任务队列,采用阻塞队列实现。当核心线程全部繁忙时,后续由execute方法提交的Runnable将存放在任务队列中,等待被线程处理。
threadFactory(可选):线程工厂。指定线程池创建线程的方式。
handler(可选):拒绝策略。当线程池中线程数达到maximumPoolSize且workQueue打满时,后续提交的任务将被拒绝,handler可以指定用什么方式拒绝任务。
CPU密集型:核心线程数 = CPU核数 + 1
IO密集型:核心线程数 = CPU核数 * 2
IO密集型:核心线程数 = CPU核数 * 2
任务队列workQueue
SynchronousQueue:同步队列
同步队列。这是一个内部没有任何容量的阻塞队列,任何一次插入操作的元素都要等待相对的删除/读取操作,否则进行插入操作的线程就要一直等待,反之亦然。
LinkedBlockingQueue:无界队列
无界队列(严格来说并非无界,上限是Integer.MAX_VALUE),基于链表结构。
使用无界队列后,当核心线程都繁忙时,后续任务可以无限加入队列,因此线程池中线程数不会超过核心线程数。这种队列可以提高线程池吞吐量,但代价是牺牲内存空间,甚至会导致内存溢出。另外,使用它时可以指定容量,这样它也就是一种有界队列了。
使用无界队列后,当核心线程都繁忙时,后续任务可以无限加入队列,因此线程池中线程数不会超过核心线程数。这种队列可以提高线程池吞吐量,但代价是牺牲内存空间,甚至会导致内存溢出。另外,使用它时可以指定容量,这样它也就是一种有界队列了。
ArrayBlockingQueue:有界队列
有界队列,基于数组实现。在线程池初始化时,指定队列的容量,后续无法再调整。这种有界队列有利于防止资源耗尽,但可能更难调整和控制。
额外
PriorityBlockingQueue:支持优先级排序的无界阻塞队列
存放在PriorityBlockingQueue中的元素必须实现Comparable接口,这样才能通过实现compareTo()方法进行排序。优先级最高的元素将始终排在队列的头部;只能保证第一个的顺序是正确的,后面的无法保证。
DelayQueue:延迟队列
基于二叉堆实现,同时具备:无界队列、阻塞队列、优先队列的特征。DelayQueue延迟队列中存放的对象,必须是实现Delayed接口的类对象。通过执行时延从队列中提取任务,时间没到任务取不出来。更多内容请见DelayQueue。
LinkedBlockingDeque:双端队列
基于链表实现,既可以从尾部插入/取出元素,还可以从头部插入元素/取出元素。
LinkedTransferQueue
由链表结构组成的无界阻塞队列。这个队列比较特别的时,采用一种预占模式,意思就是消费者线程取元素时,如果队列不为空,则直接取走数据,若队列为空,那就生成一个节点(节点元素为null)入队,然后消费者线程被等待在这个节点上,后面生产者线程入队时发现有一个元素为null的节点,生产者线程就不入队了,直接就将元素填充到该节点,并唤醒该节点等待的线程,被唤醒的消费者线程取走元素。
这次不空就拿数据,空了就挖个坑,下次生产者就填坑
拒绝策略handler
当线程池workQueue已满且无法再创建新线程池时,就要拒绝后续任务了。拒绝策略需要实现RejectedExecutionHandler接口,不过Executors框架已经为我们实现了4种拒绝策略
策略
AbortPolicy(默认) 丢弃并抛出RejectedExecutionException异常。
CallerRunsPolicy:直接运行这个任务的run方法
但并非是由线程池的线程处理,而是交由任务的调用线程处理。
因为线程池已经拒绝了。
因为线程池已经拒绝了。
DiscardPolicy:直接丢弃任务
DiscardOldestPolicy:将当前处于等待队列列头的等待任务强行取出,然后再试图将当前被拒绝的任务提交到线程池执行。
线程工厂threadFactory
这个参数不是必选项,Executors类已经为我们非常贴心地提供了一个默认的线程工厂
线程池状态
线程池有5种状态
runState表示当前线程池的状态,它是一个 volatile 变量用来保证线程之间的可见性。
状态解释
RUNNING:当创建线程池后,初始时,线程池处于RUNNING状态;
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
SHUTDOWN:如果调用了shutdown()方法,则线程池处于SHUTDOWN状态,此时线程池不能够接受新的任务,它会等待所有任务执行完毕;
状态切换:调用线程池的shutdown()接口时,线程池由RUNNING -> SHUTDOWN。
STOP:如果调用了shutdownNow()方法,则线程池处于STOP状态,此时线程池不能接受新的任务,并且会去尝试终止正在执行的任务;
状态切换:调用线程池的shutdownNow()接口时,线程池由(RUNNING or SHUTDOWN ) -> STOP。
TIDYING:当所有的任务已终止,ctl记录的”任务数量”为0,线程池会变为TIDYING状态。
状态切换:当线程池在SHUTDOWN状态下,阻塞队列为空并且线程池中执行的任务也为空时,就会由 SHUTDOWN -> TIDYING。
当线程池在STOP状态下,线程池中执行的任务为空时,就会由STOP -> TIDYING。
当线程池在STOP状态下,线程池中执行的任务为空时,就会由STOP -> TIDYING。
TERMINATED:当线程池处于SHUTDOWN或STOP状态,并且所有工作线程已经销毁,任务缓存队列已经清空或执行结束后,线程池被设置为TERMINATED状态。
状态切换:线程池处在TIDYING状态时,执行完terminated()之后,就会由 TIDYING -> TERMINATED。
初始化&容量调整&关闭
线程初始化
默认情况下,创建线程池之后,线程池中是没有线程的,需要提交任务之后才会创建线程。
立即初始化线程
prestartCoreThread():boolean prestartCoreThread(),初始化一个核心线程
prestartAllCoreThreads():int prestartAllCoreThreads(),
线程池关闭
shutdown()
执行完任务后终止。而是要等所有任务缓存队列中的任务都执行完后才终止,但再也不会接受新的任务
shutdownNow()
立即终止线程池,并尝试打断正在执行的任务,并且清空任务缓存队列,返回尚未执行的任务
线程池容量调整
setCorePoolSize
设置核心池大小
setMaximumPoolSize
设置线程池最大能创建的线程数目大小 当上述参数从小变大时,ThreadPoolExecutor进行线程赋值,还可能立即创建新的线程来执行任务。
线程池使用及种类
使用
通过构造方法使用ThreadPoolExecutor是线程池最直接的使用方式
种类
固定数量的线程池 newFixedThreadPool
固定容量线程池。其特点是最大线程数就是核心线程数,意味着线程池只能创建核心线程,keepAliveTime为0,即线程执行完任务立即回收。任务队列未指定容量,代表使用默认值Integer.MAX_VALUE。适用于需要控制并发线程的场景。
LinkedBlockingQueue无界队列
构造函数的keepAliveTime=0,然后核心线程个数和最大线程个数都限制死了,所以虽然用的是LinkedBlockingQueue无界队列,但是其实不会用到无界的特性,其实是当有界来用。
缓存线程池 newCachedThreadPool
缓存线程池。没有核心线程,普通线程数量为Integer.MAX_VALUE(可以理解为无限),线程闲置60s后回收,任务队列使用SynchronousQueue这种无容量的同步队列。适用于任务量大但耗时低的场景。
SynchronousQueue同步队列
只有一个线程的线程池 newSingleThreadPool
单线程线程池。特点是线程池中只有一个线程(核心线程),线程执行完任务立即回收,使用有界阻塞队列(容量未指定,使用默认值Integer.MAX_VALUE)
LinkedBlockingQueue无界队列
核心线程和最大线程都是1,keepAliveTime=0,这个也是拿LinkedBlockingQueue无界队列当有界队列使用。
延时线程池 newScheduleThreadPool
定时线程池。指定核心线程数量,普通线程数量无限,线程执行完任务立即回收,任务队列为延时阻塞队列。这是一个比较特别的线程池,适用于执行定时或周期性的任务。
DelayQueue延迟队列
因为需要延时,这个使用DelayQueue延迟队列就没有任何毛病
并行线程池 newWorkStealingPool
子主题
线程池任务的执行
父节点链接
线程池线程数的确定
CPU密集型,则线程池大小设置为N+1;
尽量使用较小的线程池,一般为CPU核心数+1。 因为CPU密集型任务使得CPU使用率很高,若开过多的线程数,只能增加上下文切换的次数,因此会带来额外的开销。
IO密集型,则线程池大小设置为2N+1。
可以使用稍大的线程池,一般为2*CPU核心数。 IO密集型任务CPU使用率并不高,因此可以让CPU在等待IO的时候去处理别的任务,充分利用CPU时间。
混合型任务
具体情况具体分析,可通过压测分析
并发工具类
CyclicBarrier
栅栏锁
CountDownLatch闭锁
用途
实现最大的并行性:有时我们想同时启动多个线程,实现最大程度的并行性。例如,我们想测试一个单例类。如果我们创建一个初始计数器为1的CountDownLatch,并让其他所有线程都在这个锁上等待,只需要调用一次countDown()方法就可以让其他所有等待的线程同时恢复执行。
开始执行前等待N个线程完成各自任务:例如应用程序启动类要确保在处理用户请求前,所有N个外部系统都已经启动和运行了
死锁检测:一个非常方便的使用场景是你用N个线程去访问共享资源,在每个测试阶段线程数量不同,并尝试产生死锁。
多等一
Semaphpre信号量
限制最大线程数
多线程访问有限资源的并发访问控制
Exchanger
多线程集合
同步集合
并发集合
ConcuerrentHashMap
ConcuerrentLinkedQueue
ConcuerrentSkipListMap
ConcuerrentSkipListSet
CopyOnWriteList
阻塞队列
包concurrent
java.util.concurrent
提供大部分关于并发的接口和类,如BlockingQueue,Callable,ConcurrentHashMap,ExecutorService, Semaphore等
java.util.concurrent.atomic
提供所有原子操作的类, 如AtomicInteger, AtomicLong等
引用原子类
java.util.concurrent.locks
Lock, ReentrantLock, ReadWriteLock, Condition等;
其他
Threadlocal
Fork/Join
子主题
JVM
基础概念
JVM、JRE和JDK的关系
JVM
Java Virtual Machine是Java虚拟机,Java程序需要运行在虚拟机上,不同的平台有自己的虚拟机,因此Java语言可以实现跨平台。
JRE
Java Runtime Environment包括Java虚拟机和Java程序所需的核心类库等。核心类库主要是java.lang包:包含了运行Java程序必不可少的系统类,如基本数据类型、基本数学函数、字符串处理、线程、异常处理类等,系统缺省加载这个包
如果想要运行一个开发好的Java程序,计算机中只需要安装JRE即可。
如果想要运行一个开发好的Java程序,计算机中只需要安装JRE即可。
JDK
为什么JRE和JDK分开
一个是运行环境,一个是开发大礼包。部署只要JRE就行了。
Java Development Kit是提供给Java开发人员使用的,其中包含了Java的开发工具,也包括了JRE。所以安装了JDK,就无需再单独安装JRE了。其中的开发工具:编译工具(javac.exe),打包工具(jar.exe)等
为什么说java是跨平台语言
这个跨平台是中间语言(JVM)实现的跨平台
java有JVM从软件层面屏蔽了底层硬件、指令层面的细节让他兼容各种系统
java有JVM从软件层面屏蔽了底层硬件、指令层面的细节让他兼容各种系统
JVM运行时数据区
解释
方法区(Method Area)----线程共享的内存区域,
它用于存储已被Java虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
它用于存储已被Java虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
Java堆(Java Heap)----线程共享的内存区域,
存放对象实例,所有的对象实例以及数组都要在堆上分配
存放对象实例,所有的对象实例以及数组都要在堆上分配
从内存回收角度来看java堆可分为:新生代和老生代
从内存分配的角度看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区
Java虚拟机栈(Java Virtual Machine Stacks)
生命周期和线程相同
生命周期和线程相同
概念
也叫局部变量表,它是Java方法执行的内存模型。里面会对局部变量,动态链表,方法出口,栈的操作(入栈和出栈)进行存储,且线程独享。同时如果我们听到局部变量表,那也是在说虚拟机栈。
虚拟机栈存在的异常
如果线程请求的栈的深度大于虚拟机栈的最大深度,就会报堆栈溢出错误 **StackOverflowError** (这种错误经常出现在递归中)。Java虚拟机也可以动态扩展,但随着扩展会不断地申请内存,当无法申请足够内存时就会报错 **OutOfMemoryError**。
虚拟机栈的生命周期
对于栈来说,不存在垃圾回收。只要程序运行结束,栈的空间自然就会释放了。栈的生命周期和所处的线程是一致的。
这里补充一句:8种基本类型的变量+对象的引用变量(地址值、也叫指针)都是在栈里面分配内存。
这里补充一句:8种基本类型的变量+对象的引用变量(地址值、也叫指针)都是在栈里面分配内存。
虚拟机栈的执行
我们经常说的栈帧数据,说白了在JVM中叫栈帧,放到Java中其实就是方法,它也是存放在栈中的。
栈中的数据都是以栈帧的格式存在,它是一个关于方法和运行期数据的数据集。比如我们执行一个方法a,就会对应产生一个栈帧A1,然后A1会被压入栈中。同理方法b会有一个B1,方法c会有一个C1,等到这个线程执行完毕后,栈会先弹出C1,后B1,A1。它是一个先进后出,后进先出原则。
栈中的数据都是以栈帧的格式存在,它是一个关于方法和运行期数据的数据集。比如我们执行一个方法a,就会对应产生一个栈帧A1,然后A1会被压入栈中。同理方法b会有一个B1,方法c会有一个C1,等到这个线程执行完毕后,栈会先弹出C1,后B1,A1。它是一个先进后出,后进先出原则。
局部变量的复用
局部变量表用于存放方法参数和方法内部所定义的局部变量。它的容量是以Slot为最小单位,一个slot可以存放32位以内的数据类型。
虚拟机通过索引定位的方式使用局部变量表,范围为[0,局部变量表的slot的数量]。方法中的参数就会按一定顺序排列在这个局部变量表中,至于怎么排的我们可以先不关心。而为了节省栈帧空间,这些slot是可以复用的,当方法执行位置超过了某个变量,那么这个变量的slot可以被其它变量复用。当然如果需要复用,那我们的垃圾回收自然就不会去动这些内存。
虚拟机通过索引定位的方式使用局部变量表,范围为[0,局部变量表的slot的数量]。方法中的参数就会按一定顺序排列在这个局部变量表中,至于怎么排的我们可以先不关心。而为了节省栈帧空间,这些slot是可以复用的,当方法执行位置超过了某个变量,那么这个变量的slot可以被其它变量复用。当然如果需要复用,那我们的垃圾回收自然就不会去动这些内存。
如果线程请求的栈的深度大于虚拟机栈的最大深度,就会报堆栈溢出错误 **StackOverflowError** (这种错误经常出现在递归中)。Java虚拟机也可以动态扩展,但随着扩展会不断地申请内存,当无法申请足够内存时就会报错 **OutOfMemoryError**。
本地方法栈(Native Method Stack)
本地方法栈很好理解,他很栈很像,只不过方法上带了 native 关键字的栈字
即本地方法栈中就是C和C++的代码
本地方法栈很好理解,他很栈很像,只不过方法上带了 native 关键字的栈字
即本地方法栈中就是C和C++的代码
程序计数器----其实就是一个指针
它指向了我们程序中下一句需要执行的指令,它也是内存区域中唯一一个不会出现OutOfMemoryError的区域,而且占用内存空间小到基本可以忽略不计。这个内存仅代表当前线程所执行的字节码的行号指示器,字节码解析器通过改变这个计数器的值选取下一条需要执行的字节码指令。
它指向了我们程序中下一句需要执行的指令,它也是内存区域中唯一一个不会出现OutOfMemoryError的区域,而且占用内存空间小到基本可以忽略不计。这个内存仅代表当前线程所执行的字节码的行号指示器,字节码解析器通过改变这个计数器的值选取下一条需要执行的字节码指令。
说一下堆栈的区别
堆的物理地址分配对对象是不连续的。因此性能慢些。在GC的时候也要考虑到不连续的分配,所以有各种算法。比如,标记-消除,复制,标记-压缩,分代(即新生代使用复制算法,老年代使用标记——压缩)
栈使用的是数据结构中的栈,先进后出的原则,物理地址分配是连续的。所以性能快。
内存分别
堆因为是不连续的,所以分配的内存是在运行期确认的,因此大小不固定。一般堆大小远远大于栈。
栈是连续的,所以分配的内存大小要在编译期就确认,大小是固定的。
存放的内容
堆存放的是对象的实例和数组。因此该区更关注的是数据的存储
栈存放:局部变量,操作数栈,返回结果。该区更关注的是程序方法的执行。
PS:
静态变量放在方法区
静态的对象还是放在堆。
程序的可见度
堆对于整个应用程序都是共享、可见的。
栈只对于线程是可见的。所以也是线程私有。他的生命周期和线程相同。
栈使用的是数据结构中的栈,先进后出的原则,物理地址分配是连续的。所以性能快。
内存分别
堆因为是不连续的,所以分配的内存是在运行期确认的,因此大小不固定。一般堆大小远远大于栈。
栈是连续的,所以分配的内存大小要在编译期就确认,大小是固定的。
存放的内容
堆存放的是对象的实例和数组。因此该区更关注的是数据的存储
栈存放:局部变量,操作数栈,返回结果。该区更关注的是程序方法的执行。
PS:
静态变量放在方法区
静态的对象还是放在堆。
程序的可见度
堆对于整个应用程序都是共享、可见的。
栈只对于线程是可见的。所以也是线程私有。他的生命周期和线程相同。
成员变量在堆上
局部变量在栈上
静态变量在方法区中
局部变量在栈上
静态变量在方法区中
JAVA内存结构
JVM的垃圾回收机制
概念
程序在运行过程中,会产生大量的内存垃圾(一些没有引用指向的内存对象都属于内存垃圾,因为这些对象已经无法访问,程序用不了它们了,对程序而言它们已经死亡),为了确保程序运行时的性能,java虚拟机在程序运行的过程中不断地进行自动的垃圾回收(GC)
两个生僻的东西
手动回收垃圾
System.gc();
建议执行垃圾收集器,但是他是否执行,什么时候执行却都是不可知的
建议执行垃圾收集器,但是他是否执行,什么时候执行却都是不可知的
finalize方法
属于Object的方法
在类中重写此方法,则在垃圾收集器删除对象之前会执行,可以借此留下信息或者重新“复活”
在类中重写此方法,则在垃圾收集器删除对象之前会执行,可以借此留下信息或者重新“复活”
新生代、老年代和永久代
概念
新生代 ( Young ) 又被划分为三个区域:Eden、From Survivor、To Survivor。Edem : From Survivor : To Survivor = 8 : 1 : 1
通过参数 –XX:SurvivorRatio 来设定
JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的
新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2
通过参数 –XX:NewRatio 来指定
永久代就是JVM的方法区。在这里都是放着一些被虚拟机加载的类信息,静态变量,常量等数据。这个区中的东西比老年代和新生代更不容易回收。
分代的原因
更便于回收,采用最适当的收集算法
新生代中,每次垃圾收集时都发现大批对象死去,只有少量对象存活,便采用了复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须采用“标记-清理”或者“标记-整理”算法。
垃圾回收的过程
初始:
新生代分为Eden和Survivor (From与To,这里简称一个区)两个区。加上老年代就这三个区。数据会首先分配到Eden区当中(当然也有特殊情况,如果是大对象那么会直接放入到老年代(大对象是指需要大量连续内存空间的java对象)
新生代分为Eden和Survivor (From与To,这里简称一个区)两个区。加上老年代就这三个区。数据会首先分配到Eden区当中(当然也有特殊情况,如果是大对象那么会直接放入到老年代(大对象是指需要大量连续内存空间的java对象)
过程:
1、当Eden没有足够空间的时候就会触发jvm发起一次Minor GC,。如果对象经过一次Minor-GC还存活,并且又能被Survivor0空间接受,那么将被移动到Survivor空间当中。并将其年龄设为1;第二次Minor GC时Eden和S0活的会到S1,来回反复。(两个survivor可以保证内存连续)
2、对象在Survivor每熬过一次Minor GC,年龄就加1,当年龄达到一定的程度(默认为15)时,就会被晋升到老年代中了;
1、当Eden没有足够空间的时候就会触发jvm发起一次Minor GC,。如果对象经过一次Minor-GC还存活,并且又能被Survivor0空间接受,那么将被移动到Survivor空间当中。并将其年龄设为1;第二次Minor GC时Eden和S0活的会到S1,来回反复。(两个survivor可以保证内存连续)
2、对象在Survivor每熬过一次Minor GC,年龄就加1,当年龄达到一定的程度(默认为15)时,就会被晋升到老年代中了;
晋升老年代的年龄是可以设置的。
垃圾回收器的种类
Minor GC
Minor GC是新生代GC,指的是发生在新生代的垃圾收集动作。由于java对象大都是朝生夕死的,所以Minor GC非常频繁,一般回收速度也比较快。
触发条件
1、eden区满时,触发MinorGC。即申请一个对象时,发现eden区不够用,则触发一次MinorGC。
2、新创建的对象大小 > Eden所剩空间
1、eden区满时,触发MinorGC。即申请一个对象时,发现eden区不够用,则触发一次MinorGC。
2、新创建的对象大小 > Eden所剩空间
Major GC
Major GC是老年代GC,指的是发生在老年代的GC,通常执行Major GC会连着Minor GC一起执行。Major GC的速度要比Minor GC慢的多。
触发条件:
1、长期存活的对象将进入老年代
2、MinorGC后存活的对象超过了老年代剩余空间
3、永久代空间不足
4、执行System.gc()
5、CMS GC异常
6、堆内存分配很大的对象
1、长期存活的对象将进入老年代
2、MinorGC后存活的对象超过了老年代剩余空间
3、永久代空间不足
4、执行System.gc()
5、CMS GC异常
6、堆内存分配很大的对象
Full GC
Full GC是清理整个堆空间,包括年轻代和老年代
触发条件和Major GC一样
1)调用System.gc()时,系统建议执行Full GC,但是不必然执行。
(2)老年代空间不足。
(3)方法区空间不足。
(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存。
(5)由Eden区、survivor space0(From Space)区向survivor space1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。
说明:Full GC 是开发或调优中尽量要避免的。这样暂时时间会短一些
出现OOM一定会出现Full GC
(2)老年代空间不足。
(3)方法区空间不足。
(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存。
(5)由Eden区、survivor space0(From Space)区向survivor space1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。
说明:Full GC 是开发或调优中尽量要避免的。这样暂时时间会短一些
出现OOM一定会出现Full GC
如何判断对象是否存活
引用计数法(主流JAVA虚拟机都不用)
概念
每个对象在创建的时候,就给这个对象绑定一个计数器。每当有一个引用指向该对象时,计数器加一;每当有一个指向它的引用被删除时,计数器减一。这样,当没有引用指向该对象时,计数器为0就代表该对象死亡
优点
引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法,
缺点
很难解决对象之间相互循环引用的问题。
MyObject myObject1 = new MyObject();
MyObject myObject2 = new MyObject();
myObject1.ref = myObject2;
myObject2.ref = myObject1;
myObject1 = null;
myObject2 = null;
MyObject myObject2 = new MyObject();
myObject1.ref = myObject2;
myObject2.ref = myObject1;
myObject1 = null;
myObject2 = null;
当代码执行完line4时,两个对象的引用计数均为2。此时将myObject1和myObject2分别置为null,以前一个对象为例,它的引用计数将减1。若要满足垃圾回收的条件,需要清除myObject2中的ref这个引用,而要清除掉这个引用的前提条件是myObject2引用的对象被回收,可是该对象的引用计数也为1,因为myObject1.ref指向了它。以此类推,也就进入一种死循环的状态。myObject1和myObject2将不能被回收,因为他们的引用计数无法为零。
可达性分析法
该种方法是从GC Roots开始向下搜索,搜索所走过的路径为引用链。当一个对象到GC Roots没用任何引用链时,则证明此对象是不可用的,表示可以回收。
a, b 对象可回收,就一定会被回收吗?
对象的 finalize 方法可以起死回生。
在此方法里将当前对象与 GC Roots 关联,这样执行 finalize 方法之后,GC 会再次判断对象是否可达
在此方法里将当前对象与 GC Roots 关联,这样执行 finalize 方法之后,GC 会再次判断对象是否可达
可以作为GC Roots 的对象(两栈两方法)
1、虚拟机栈(栈帧中的本地变量表)中引用的对象;(局部变量)
方法中的栈,栈中有他的栈成员 Integer a = XXX,当方法没有被释放,没有出栈的时候,方法没有被弹出的时候,那Integer a 所引用的对象也是不会被回收的,在什么情况下回收呢,就是这个对象没有挂在根上,就会被回收。
2、本地方法栈中JNI(即一般说的Native方法)引用的对象;
3、方法区中常量引用的对象;
4、方法区中类静态属性引用的对象;
垃圾回收机制策略(也称为GC的算法)
引用计数法
复制算法
概念
该算法将内存平均分成两部分,然后每次只使用其中的一部分,当这部分内存满的时候,将内存中所有存活的对象复制到另一个内存中,然后将之前的内存清空,只使用这部分内存,循环下去。
这个算法与标记-整理算法的区别在于,该算法不是在同一个区域复制,而是将所有存活的对象复制到另一个区域内。
这个算法与标记-整理算法的区别在于,该算法不是在同一个区域复制,而是将所有存活的对象复制到另一个区域内。
优点
在存活对象不多的情况下,性能高,能解决内存碎片和java垃圾回收算法之-标记清除 中导致的引用更新问题。
所以适合新生代
缺点
会造成一部分的内存浪费。不过可以根据实际情况,将内存块大小比例适当调整;
如果存活对象的数量比较大,复制算法的性能会变得很差。
如果存活对象的数量比较大,复制算法的性能会变得很差。
应用场景
1、复制算法一般是使用在新生代中,因为新生代中的对象一般都是朝生夕死的,存活对象的数量并不多(98%熬不过第一轮收集),这样使用复制算法进行拷贝时效率比较高。
2、每次分配内存只使用Eden和其中一块Survivor。 发生垃圾搜集时, 将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上, 然后直接清理掉Eden和已用过的那块Survivor空 间;
3、不过jvm在应用复制算法时,并不是把内存按照1:1来划分的,这样太浪费内存空间了。一般的jvm都是8:1。也即是说,Eden区:From区:To区域的比例是始终有90%的空间是可以用来创建对象的,而剩下的10%用来存放回收后存活的对象。
2、每次分配内存只使用Eden和其中一块Survivor。 发生垃圾搜集时, 将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上, 然后直接清理掉Eden和已用过的那块Survivor空 间;
3、不过jvm在应用复制算法时,并不是把内存按照1:1来划分的,这样太浪费内存空间了。一般的jvm都是8:1。也即是说,Eden区:From区:To区域的比例是始终有90%的空间是可以用来创建对象的,而剩下的10%用来存放回收后存活的对象。
标记–清除算法(Mark-Sweep)
概念
为每个对象存储一个标记位,记录对象的状态(活着或是死亡)。
分为两个阶段,一个是标记阶段,这个阶段内,为每个对象更新标记位,检查对象是否死亡;
第二个阶段是清除阶段,该阶段对死亡的对象进行清除,执行 GC 操作。
分为两个阶段,一个是标记阶段,这个阶段内,为每个对象更新标记位,检查对象是否死亡;
第二个阶段是清除阶段,该阶段对死亡的对象进行清除,执行 GC 操作。
优点
是可以解决循环引用的问题
必要时才回收(内存不足时)
必要时才回收(内存不足时)
缺点
回收时,应用需要挂起,也就是stop the world。
尤其是要扫描的对象比较多的时候标记和清除的效率不高,
会造成内存碎片(会导致明明有内存空间,但是由于不连续,申请稍微大一些的对象无法做到)
尤其是要扫描的对象比较多的时候标记和清除的效率不高,
会造成内存碎片(会导致明明有内存空间,但是由于不连续,申请稍微大一些的对象无法做到)
应用场景
该算法一般应用于老年代,因为老年代的对象生命周期比较长。
标记–整理算法
概念
标记清除算法和标记整理算法非常相同,但是标记整理算法在标记清除算法之上解决内存碎片化(有些人叫"标记整理算法"为"标记压缩算法")
1、在标记阶段,该算法也将所有对象标记为存活和死亡两种状态;
2、不同的是,在第二个阶段,该算法并没有直接对死亡的对象进行清理,而是将所有存活的对象整理一下,放到另一处空间,(则可以整理出连续的大存储空间)
3、然后把剩下的所有对象全部清除。这样就达到了标记-整理的目的。
1、在标记阶段,该算法也将所有对象标记为存活和死亡两种状态;
2、不同的是,在第二个阶段,该算法并没有直接对死亡的对象进行清理,而是将所有存活的对象整理一下,放到另一处空间,(则可以整理出连续的大存储空间)
3、然后把剩下的所有对象全部清除。这样就达到了标记-整理的目的。
优点
解决标记清除算法出现的内存碎片问题,
缺点
压缩阶段,由于移动了可用对象,需要去更新引用。
应用场景
该算法一般应用于老年代,因为老年代的对象生命周期比较长。
分代算法(主要的算法就是上面四种,这个是附加的)
概念
这种算法,根据对象的存活周期的不同将内存划分成几块,新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。可以用抓重点的思路来理解这个算法。
新生代对象朝生夕死,对象数量多,只要重点扫描这个区域,那么就可以大大提高垃圾收集的效率。另外老年代对象存储久,无需经常扫描老年代,避免扫描导致的开销。
新生代对象朝生夕死,对象数量多,只要重点扫描这个区域,那么就可以大大提高垃圾收集的效率。另外老年代对象存储久,无需经常扫描老年代,避免扫描导致的开销。
新生代
在新生代,每次垃圾收集器都发现有大批对象死去,只有少量存活,采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
老年代
而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须“标记清除法或者标记整理算法进行回收。
垃圾回收器
概念
垃圾收集器是垃圾回收算法(引用计数法、标记清除法、标记整理法、复制算法)的具体实现,不同垃圾收集器、不同版本的JVM所提供的垃圾收集器可能会有很在差别。
分类
新生代回收器
Serial复制算法
Serial 收集器:新生代。发展历史最悠久的收集器。它是一个单线程收集器,它只会使用一个 CPU 或者线程去完成垃圾收集工作,而且在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。
新生代收集器,使用复制算法收集新生代垃圾。
单线程的收集器,GC工作时,其它所有线程都将停止工作。
简单高效,适合单 CPU 环境。单线程没有线程交互的开销,因此拥有最高的单线程收集效率。
单线程的收集器,GC工作时,其它所有线程都将停止工作。
简单高效,适合单 CPU 环境。单线程没有线程交互的开销,因此拥有最高的单线程收集效率。
设置垃圾收集器:"-XX:+UseSerialGC" --添加该参数来显式的使用改垃圾收集器;
ParNew复制算法
ParNew 收集器:新生代。Serial 的多线程版本,即同时启动多个线程去进行垃圾收集。
新生代收集器。ParNew垃圾收集器是Serial收集器的多线程版本,采用复制算法。
除了多线程外,其余的行为、特点和Serial收集器一样。
只有它能与 CMS 收集器配合使用。
但在单个CPU环境中,不比Serail收集器好,多线程使用它比较好。
除了多线程外,其余的行为、特点和Serial收集器一样。
只有它能与 CMS 收集器配合使用。
但在单个CPU环境中,不比Serail收集器好,多线程使用它比较好。
设置垃圾收集器:"-XX:+UseParNewGC" --强制指定使用ParNew;
设置垃圾收集器: "-XX:+UseConcMarkSweepGC" --指定使用CMS后,会默认使用ParNew作为新生代收集器;
设置垃圾收集器参数:"-XX:ParallelGCThreads" --指定垃圾收集的线程数量,ParNew默认开启的收集线程与CPU的数量相同;
设置垃圾收集器: "-XX:+UseConcMarkSweepGC" --指定使用CMS后,会默认使用ParNew作为新生代收集器;
设置垃圾收集器参数:"-XX:ParallelGCThreads" --指定垃圾收集的线程数量,ParNew默认开启的收集线程与CPU的数量相同;
Parallel Scavenge复制算法
Parallel Scavenge 收集器:新生代。和 ParNew 的关注点不一样,该收集器更关注吞吐量,尽快地完成计算任务。也是多线程的。
新生代收集器。
采用复制算法。
多线程收集。
与ParNew 不同的是:高吞吐量为目标,(减少垃圾收集时间,让用户代码获得更长的运行时间)
采用复制算法。
多线程收集。
与ParNew 不同的是:高吞吐量为目标,(减少垃圾收集时间,让用户代码获得更长的运行时间)
设置垃圾收集器:"-XX:+UseParallelGC" --添加该参数来显式的使用改垃圾收集器;
设置垃圾收集器参数:"-XX:MaxGCPauseMillis" --控制垃圾回收时最大的停顿时间(单位ms)
设置垃圾收集器参数:"-XX:GCTimeRatio" --控制程序运行的吞吐量大小吞吐量大小=代码执行时间/(代码执行时间+gc回收的时间)
设置垃圾收集器参数:"-XX:UseAdaptiveSizePolicy" --内存调优交给虚拟机管理
设置垃圾收集器参数:"-XX:MaxGCPauseMillis" --控制垃圾回收时最大的停顿时间(单位ms)
设置垃圾收集器参数:"-XX:GCTimeRatio" --控制程序运行的吞吐量大小吞吐量大小=代码执行时间/(代码执行时间+gc回收的时间)
设置垃圾收集器参数:"-XX:UseAdaptiveSizePolicy" --内存调优交给虚拟机管理
Serial/ParNew和Parallel Scavenge对比
前者偏重效率:会有stop the world
后者关注吞吐量: 减少垃圾收集时间,让用户代码获得更长的运行时间
后者关注吞吐量: 减少垃圾收集时间,让用户代码获得更长的运行时间
老年代回收器
Serial Old标记-整理
Serial Old 收集器:Serial 的老年代版本,使用标记 - 整理算法。
老年代收集器, 采用"标记-整理"算法。
单线程收集。
单线程收集。
在JDK1.5及之前,与Parallel Scavenge收集器搭配使用,
在JDK1.6后有Parallel Old收集器可搭配。
现在的作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用
在JDK1.6后有Parallel Old收集器可搭配。
现在的作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用
Parallel Old标记-整理
Parallnel old 收集器,多线程:Parallel 的老年代版本,使用标记 - 整理算法。
针对老年代。
采用"标记-整理"算法。
多线程收集。
但在单个CPU环境中,不比Serial Old收集器好,多线程使用它比较好。
采用"标记-整理"算法。
多线程收集。
但在单个CPU环境中,不比Serial Old收集器好,多线程使用它比较好。
设置垃圾收集器:"-XX:+UseParallelOldGC":指定使用Parallel Old收集器;
CMS标记-清除
CMS 收集器:老年代。并发收集、低停顿。是一种以获取最短回收停顿时间为目标的收集器,适用于互联网站或者 B/S 系统的服务端上。
针对老年代,采用标记-清楚法清除垃圾;
基于"标记-清除"算法(不进行压缩操作,产生内存碎片);
以获取最短回收停顿时间为目标;
并发收集、低停顿;
CMS收集器有3个明显的缺点:
1.对CPU资源非常敏感、
2.无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败
3.产生大量内存碎片
垃圾收集线程与用户线程(基本上)可以同时工作
基于"标记-清除"算法(不进行压缩操作,产生内存碎片);
以获取最短回收停顿时间为目标;
并发收集、低停顿;
CMS收集器有3个明显的缺点:
1.对CPU资源非常敏感、
2.无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败
3.产生大量内存碎片
垃圾收集线程与用户线程(基本上)可以同时工作
设置垃圾收集器:"-XX:+UseConcMarkSweepGC":指定使用CMS收集器;
整个堆
G1
G1 收集器:分代收集器。当今收集器技术发展最前沿成果之一,是一款面向服务端应用的垃圾收集器。G1可以说是CMS的终极改进版,解决了CMS内存碎片、更多的内存空间登问题。虽然流程与CMS比较相似,但底层的原理已是完全不同。
能充分利用多CPU、多核环境下的硬件优势;
可以并行来缩短(Stop The World)停顿时间;
也可以并发让垃圾收集与用户程序同时进行;
分代收集,收集范围包括新生代和老年代
能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;
能够采用不同方式处理不同时期的对象;
应用场景可以面向服务端应用,针对具有大内存、多处理器的机器;
采用标记-整理 + 复制算法来回收垃圾
可以并行来缩短(Stop The World)停顿时间;
也可以并发让垃圾收集与用户程序同时进行;
分代收集,收集范围包括新生代和老年代
能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;
能够采用不同方式处理不同时期的对象;
应用场景可以面向服务端应用,针对具有大内存、多处理器的机器;
采用标记-整理 + 复制算法来回收垃圾
G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的
初始标记(Initial Marking):这阶段仅仅只是标记GC Roots能直接关联到的对象并修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确的可用的Region中创建新对象,这阶段需要停顿线程,但是耗时很短。而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
并发标记(Concurrent Marking):从GC Roots开始对堆的对象进行可达性分析,递归扫描整个堆里的对象图,找出存活的对象,这阶段耗时较长,但是可以与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
筛选回收(Live Data Counting and Evacuation):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划。可以自由选择多个Region来构成会收集,然后把回收的那一部分Region中的存活对象==复制==到空的Region中,在对那些Region进行清空。
并发标记(Concurrent Marking):从GC Roots开始对堆的对象进行可达性分析,递归扫描整个堆里的对象图,找出存活的对象,这阶段耗时较长,但是可以与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
筛选回收(Live Data Counting and Evacuation):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划。可以自由选择多个Region来构成会收集,然后把回收的那一部分Region中的存活对象==复制==到空的Region中,在对那些Region进行清空。
搭配使用
Serial / Serial Old
ParNew / Serial Old
Parallel Scavenge / Serial Old
Serial / CMS
ParNew / CMS
Parallel Scavenge / Parallel Old
G1
ParNew / Serial Old
Parallel Scavenge / Serial Old
Serial / CMS
ParNew / CMS
Parallel Scavenge / Parallel Old
G1
JVM参数配置及调优
JVM内存参数简述
#常用的设置
-Xms:初始堆大小,JVM 启动的时候,给定堆空间大小。
-Xmx:最大堆大小,JVM 运行过程中,如果初始堆空间不足的时候,最大可以扩展到多少。
-Xmn:设置堆中年轻代大小。整个堆大小=年轻代大小+年老代大小+持久代大小。
-XX:NewSize=n 设置年轻代初始化大小大小
-XX:MaxNewSize=n 设置年轻代最大值
-XX:NewRatio=n 设置年轻代和年老代的比值。如: -XX:NewRatio=3,表示年轻代与年老代比值为 1:3,年轻代占整个年轻代+年老代和的 1/4
-XX:SurvivorRatio=n 年轻代中 Eden 区与两个 Survivor 区的比值。注意 Survivor 区有两个。8表示两个Survivor :eden=2:8 ,即一个Survivor占年轻代的1/10,默认就为8
-Xss:设置每个线程的堆栈大小。JDK5后每个线程 Java 栈大小为 1M,以前每个线程堆栈大小为 256K。
-XX:ThreadStackSize=n 线程堆栈大小
-XX:PermSize=n 设置持久代初始值
-XX:MaxPermSize=n 设置持久代大小
-XX:MaxTenuringThreshold=n 设置年轻带垃圾对象最大年龄。如果设置为 0 的话,则年轻代对象不经过 Survivor 区,直接进入年老代。
#下面是一些不常用的
-XX:LargePageSizeInBytes=n 设置堆内存的内存页大小
-XX:+UseFastAccessorMethods 优化原始类型的getter方法性能
-XX:+DisableExplicitGC 禁止在运行期显式地调用System.gc(),默认启用
-XX:+AggressiveOpts 是否启用JVM开发团队最新的调优成果。例如编译优化,偏向锁,并行年老代收集等,jdk6纸之后默认启动
-XX:+UseBiasedLocking 是否启用偏向锁,JDK6默认启用
-Xnoclassgc 是否禁用垃圾回收
-XX:+UseThreadPriorities 使用本地线程的优先级,默认启用
-Xms:初始堆大小,JVM 启动的时候,给定堆空间大小。
-Xmx:最大堆大小,JVM 运行过程中,如果初始堆空间不足的时候,最大可以扩展到多少。
-Xmn:设置堆中年轻代大小。整个堆大小=年轻代大小+年老代大小+持久代大小。
-XX:NewSize=n 设置年轻代初始化大小大小
-XX:MaxNewSize=n 设置年轻代最大值
-XX:NewRatio=n 设置年轻代和年老代的比值。如: -XX:NewRatio=3,表示年轻代与年老代比值为 1:3,年轻代占整个年轻代+年老代和的 1/4
-XX:SurvivorRatio=n 年轻代中 Eden 区与两个 Survivor 区的比值。注意 Survivor 区有两个。8表示两个Survivor :eden=2:8 ,即一个Survivor占年轻代的1/10,默认就为8
-Xss:设置每个线程的堆栈大小。JDK5后每个线程 Java 栈大小为 1M,以前每个线程堆栈大小为 256K。
-XX:ThreadStackSize=n 线程堆栈大小
-XX:PermSize=n 设置持久代初始值
-XX:MaxPermSize=n 设置持久代大小
-XX:MaxTenuringThreshold=n 设置年轻带垃圾对象最大年龄。如果设置为 0 的话,则年轻代对象不经过 Survivor 区,直接进入年老代。
#下面是一些不常用的
-XX:LargePageSizeInBytes=n 设置堆内存的内存页大小
-XX:+UseFastAccessorMethods 优化原始类型的getter方法性能
-XX:+DisableExplicitGC 禁止在运行期显式地调用System.gc(),默认启用
-XX:+AggressiveOpts 是否启用JVM开发团队最新的调优成果。例如编译优化,偏向锁,并行年老代收集等,jdk6纸之后默认启动
-XX:+UseBiasedLocking 是否启用偏向锁,JDK6默认启用
-Xnoclassgc 是否禁用垃圾回收
-XX:+UseThreadPriorities 使用本地线程的优先级,默认启用
JVM的GC收集器设置
-XX:+UseSerialGC:设置串行收集器,年轻带收集器
-XX:+UseParNewGC:设置年轻代为并行收集。可与 CMS 收集同时使用。JDK5.0 以上,JVM 会根据系统配置自行设置,所以无需再设置此值。
-XX:+UseParallelGC:设置并行收集器,目标是目标是达到可控制的吞吐量
-XX:+UseParallelOldGC:设置并行年老代收集器,JDK6.0 支持对年老代并行收集。
-XX:+UseConcMarkSweepGC:设置年老代并发收集器
-XX:+UseG1GC:设置 G1 收集器,JDK1.9默认垃圾收集器
-XX:+UseParNewGC:设置年轻代为并行收集。可与 CMS 收集同时使用。JDK5.0 以上,JVM 会根据系统配置自行设置,所以无需再设置此值。
-XX:+UseParallelGC:设置并行收集器,目标是目标是达到可控制的吞吐量
-XX:+UseParallelOldGC:设置并行年老代收集器,JDK6.0 支持对年老代并行收集。
-XX:+UseConcMarkSweepGC:设置年老代并发收集器
-XX:+UseG1GC:设置 G1 收集器,JDK1.9默认垃圾收集器
调优总结
1、在实际工作中,我们可以直接将初始的堆大小与最大堆大小相等,
这样的好处是可以减少程序运行时垃圾回收次数,从而提高效率。
2、初始堆值和最大堆内存内存越大,吞吐量就越高,
但是也要根据自己电脑(服务器)的实际内存来比较。
3、最好使用并行收集器,因为并行收集器速度比串行吞吐量高,速度快。
当然,服务器一定要是多线程的
4、设置堆内存新生代的比例和老年代的比例最好为1:2或者1:3。
默认的就是1:2
5、减少GC对老年代的回收。设置新生代垃圾对象最大年龄,进量不要有大量连续内存空间的java对象,因为会直接到老年代,内存不够就会执行GC
这样的好处是可以减少程序运行时垃圾回收次数,从而提高效率。
2、初始堆值和最大堆内存内存越大,吞吐量就越高,
但是也要根据自己电脑(服务器)的实际内存来比较。
3、最好使用并行收集器,因为并行收集器速度比串行吞吐量高,速度快。
当然,服务器一定要是多线程的
4、设置堆内存新生代的比例和老年代的比例最好为1:2或者1:3。
默认的就是1:2
5、减少GC对老年代的回收。设置新生代垃圾对象最大年龄,进量不要有大量连续内存空间的java对象,因为会直接到老年代,内存不够就会执行GC
类加载机制和类加载器(ClassLoader)的详解
概念
JVM将类描述数据从.class文件中加载到内存,并对数据进行,解析和初始化,最终形成被JVM直接使用的Java类型。 类从被加载到JVM中开始,到卸载为止,整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中验证,准备,解析三个部分统称为连接。
类加载过程
JVM 中类的装载是由类加载器(ClassLoader)和它的子类来实现的,Java 中的类加载器是一个重要的 Java 运行时系统组件,它负责在运行时查找和装入类文件中的类。
由于 Java 的跨平台性,经过编译的 Java 源程序并不是一个可执行程序,而是一个或多个类文件。当 Java 程序需要使用某个类时,JVM 会确保这个类已经被加载、连接(验证、准备和解析)和初始化。类的加载是指把类的.class 文件中的数据读入到内存中,通常是创建一个字节数组读入.class 文件,然后产生与所加载类对应的 Class 对象。
加载完成后,Class 对象还不完整,所以此时的类还不可用。当类被加载后就进入连接阶段,这一阶段包括验证、准备(为静态变量分配内存并设置默认的初始值)和解析(将符号引用替换为直接引用)三个步骤。最后 JVM 对
类进行初始化,包括:1)如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类;2)如果类中存在初始化语句,就依次执行这些初始化语句。
类的加载是由类加载器完成的,类加载器包括:根加载器(BootStrap)、扩展加载器(Extension)、系统加载器(System)和用户自定义类加载器(java.lang.ClassLoader 的子类)。
从 Java 2(JDK 1.2)开始,类加载过程采取了父亲委托机制(PDM)。PDM 更好的保证了 Java 平台的安全性,在该机制中,JVM 自带的Bootstrap 是根加载器,其他的加载器都有且仅有一个父类加载器。类的加载首先请求父类加载器加载,父类加载器无能为力时才由其子类加载器自行加载。JVM 不会向 Java 程序提供对 Bootstrap 的引用。下面是关于几个类
加载器的说明:
Bootstrap:一般用本地代码实现,负责加载 JVM 基础核心类库(rt.jar);
Extension:从 java.ext.dirs 系统属性所指定的目录中加载类库,它的父加载器是 Bootstrap;
System:又叫应用类加载器,其父类是 Extension。它是应用最广泛的类加载器。它从环境变量 classpath 或者系统属性
java.class.path 所指定的目录中记载类,是用户自定义加载器的默认父加载器。
由于 Java 的跨平台性,经过编译的 Java 源程序并不是一个可执行程序,而是一个或多个类文件。当 Java 程序需要使用某个类时,JVM 会确保这个类已经被加载、连接(验证、准备和解析)和初始化。类的加载是指把类的.class 文件中的数据读入到内存中,通常是创建一个字节数组读入.class 文件,然后产生与所加载类对应的 Class 对象。
加载完成后,Class 对象还不完整,所以此时的类还不可用。当类被加载后就进入连接阶段,这一阶段包括验证、准备(为静态变量分配内存并设置默认的初始值)和解析(将符号引用替换为直接引用)三个步骤。最后 JVM 对
类进行初始化,包括:1)如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类;2)如果类中存在初始化语句,就依次执行这些初始化语句。
类的加载是由类加载器完成的,类加载器包括:根加载器(BootStrap)、扩展加载器(Extension)、系统加载器(System)和用户自定义类加载器(java.lang.ClassLoader 的子类)。
从 Java 2(JDK 1.2)开始,类加载过程采取了父亲委托机制(PDM)。PDM 更好的保证了 Java 平台的安全性,在该机制中,JVM 自带的Bootstrap 是根加载器,其他的加载器都有且仅有一个父类加载器。类的加载首先请求父类加载器加载,父类加载器无能为力时才由其子类加载器自行加载。JVM 不会向 Java 程序提供对 Bootstrap 的引用。下面是关于几个类
加载器的说明:
Bootstrap:一般用本地代码实现,负责加载 JVM 基础核心类库(rt.jar);
Extension:从 java.ext.dirs 系统属性所指定的目录中加载类库,它的父加载器是 Bootstrap;
System:又叫应用类加载器,其父类是 Extension。它是应用最广泛的类加载器。它从环境变量 classpath 或者系统属性
java.class.path 所指定的目录中记载类,是用户自定义加载器的默认父加载器。
类加载时机
类加载器
类加载机制
全盘负责
缓存机制
双亲委派
概念
所谓的双亲委派,则是先让父类加载器试图加载该Class,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父加载器,依次递归,如果父加载器可以完成类加载任务,就成功返回;只有父加载器无法完成此加载任务时,才自己去加载。
优点
解决重复加载问题
通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次
更加安全,保证核心类不会被篡改
java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。
举例
自定义类:java.lang.String (没用)
你改包名,类加载器会发现同路径已经加载一个同名的了
你在同名类里加方法,引导类加载器的核心类库API里的 String 类中并没有 main() 方法
自定义类:java.lang.ShkStart(报错:阻止创建 java.lang开头的类)
在 java.lang 包下整个 ShkStart 类 (自定义类名),java.lang 包下不允许我们自定义类
面试高频
Java类加载过程
Java类加载过程本质上就是将class文件加载到内存的过程,包括加载、连接(验证、准备、解析)、初始化,加载过程是由类加载器将类的二进制字节流加载存储到运行时内存区的方法区,将其转换为一个与目标类型对应的Class对象实例,这个Class对象会作为方法区中该类的各种数据的访问入口。连接是将类数据信息合并到JVM的运行时状态中,包含分配空间、赋值(final赋值,static只做初始化)、添加引用关系等操作。初始化阶段将所有static域按照程序指定操作执行,过程中保证按照双亲委派策略父类优先于子类,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次,并且在多线程情况下保证只有一个线程去操作静态数据。
双亲委派机制
Java的类加载是否一定遵循双亲委托模型?
1、 在实际开发中,我们可以通过自定义ClassLoader,并重写父类的loadClass方法,来打破这一机制。
2、 SPI就是打破了双亲委托机制的(SPI:服务提供发现)。
2、 SPI就是打破了双亲委托机制的(SPI:服务提供发现)。
强引用、软引用、弱引用、虚引用的区别?
强引用
概念
我们平常典型编码Object obj = new Object()中的obj就是强引用。通过关键字new创建的对象所关联的引用就是强引用。
特点
当JVM内存空间不足,JVM宁愿抛出OutOfMemoryError运行时错误(OOM),使程序异常终止,也不会靠随意回收具有强引用的“存活”对象来解决内存不足的问题。对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为 null,就是可以被垃圾收集的了,具体回收时机还是要看垃圾收集策略。
应用
项目中到处都是。
软引用
概念
软引用通过SoftReference类实现。 软引用的生命周期比强引用短一些。
特点
只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象:即JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。后续,我们可以调用ReferenceQueue的poll()方法来检查是否有它所关心的对象被回收。如果队列为空,将返回一个null,否则该方法返回队列中前面的一个Reference对象。
应用
软引用通常用来实现内存敏感的缓存。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存
实例
图片缓存框架中,“内存缓存”中的图片是以这种引用来保存,使得JVM在发生OOM之前,可以回收这部分缓存。
弱引用
概念
弱引用通过WeakReference类实现。 弱引用的生命周期比软引用短。
特点
。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。由于垃圾回收器是一个优先级很低的线程,因此不一定会很快回收弱引用的对象。弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。
应用
弱应用同样可用于内存敏感的缓存。
实例
在静态内部类中,经常会使用虚引用。例如,一个类发送网络请求,承担callback的静态内部类,则常以虚引用的方式来保存外部类(宿主类)的引用,当外部类需要被JVM回收时,不会因为网络请求没有及时回来,导致外部类不能被回收,引起内存泄漏。
虚引用
概念
虚引用也叫幻象引用,通过PhantomReference类来实现。无法通过虚引用访问对象的任何属性或函数。幻象引用仅仅是提供了一种确保对象被 finalize 以后,做某些事情的机制。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。
特点
虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
ReferenceQueue queue = new ReferenceQueue ();
PhantomReference pr = new PhantomReference (object, queue);
程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经
ReferenceQueue queue = new ReferenceQueue ();
PhantomReference pr = new PhantomReference (object, queue);
程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经
应用
可用来跟踪对象被垃圾回收器回收的活动,当一个虚引用关联的对象被垃圾收集器回收之前会收到一条系统通知。
实例
一种引用的get()方法返回总是null,所以,可以想象,在平常的项目开发肯定用的少。
强引用就像大老婆,关系很稳固。
软引用就像二老婆,随时有失宠的可能,但也有扶正的可能。
弱引用就像情人,关系不稳定,可能跟别人跑了。
幻像引用就是梦中情人,只在梦里出现过。
软引用就像二老婆,随时有失宠的可能,但也有扶正的可能。
弱引用就像情人,关系不稳定,可能跟别人跑了。
幻像引用就是梦中情人,只在梦里出现过。
Java OOM 分析
内存泄漏和内存溢出
内存溢出(out of memory)
(out of memory)通俗理解就是内存不够,通常在运行大型软件或游戏时,软件或游戏所需要的内存远远超出了你主机内安装的内存所承受大小,就叫内存溢出。
内存泄漏(Memory Leak)
指程序中己[动态分配]的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果
内存泄漏,即部分对象虽然已经不再使用,但是因为有root持有引用,所以并没有被销毁,所占用的内存一直没有被释放。一次两次发生影响不大。如果频繁发生,那么可用内存会渐渐不足,最终在某一次请求内存时发现内存不足而发生oom。这里要明确一个概念,只有强引用会发生内存泄漏,而weak等引用因为其特殊机制,所以影响不大。
泄露影响比较大的就是一些大对象,常见的比如某些资源,bitmap,以及activity。
泄露影响比较大的就是一些大对象,常见的比如某些资源,bitmap,以及activity。
内存溢出的原因
1.内存中加载的数据量过于庞大,如一次从数据库取出过多数据;
2.集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;
3.代码中存在死循环或循环产生过多重复的对象实体;
4.使用的第三方软件中的BUG;
5.启动参数内存值设定的过小
2.集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;
3.代码中存在死循环或循环产生过多重复的对象实体;
4.使用的第三方软件中的BUG;
5.启动参数内存值设定的过小
启动参数内存值设定的过小
第一步,修改JVM启动参数,直接增加内存。(-Xms,-Xmx参数一定不要忘记加。)
第二步,检查错误日志,查看“OutOfMemory”错误前是否有其它异常或错误。
第三步,对代码进行走查和分析,找出可能发生内存溢出的位置
第四步,使用内存查看工具动态查看内存使用情况
第二步,检查错误日志,查看“OutOfMemory”错误前是否有其它异常或错误。
第三步,对代码进行走查和分析,找出可能发生内存溢出的位置
第四步,使用内存查看工具动态查看内存使用情况
排查对象
1.检查对数据库查询中,是否有一次获得全部数据的查询。一般来说,如果一次取十万条记录到内存,就可能引起内 存溢出。这个问题比较隐蔽,在上线前,数据库中数据较少,不容易出问题,上线后,数据库中数据多了,一次查 询就有可能引起内存溢出。因此对于数据库查询尽量采用分页的方式查询。
2.检查代码中是否有死循环或递归调用。
3.检查是否有大循环重复产生新对象实体。
4.检查List、MAP等集合对象是否有使用完后,未清除的问题。List、MAP等集合对象会始终存有对对象的引用,使得这些对象不能被GC回收
2.检查代码中是否有死循环或递归调用。
3.检查是否有大循环重复产生新对象实体。
4.检查List、MAP等集合对象是否有使用完后,未清除的问题。List、MAP等集合对象会始终存有对对象的引用,使得这些对象不能被GC回收
static
static这个关键字使一个变量变为只和这个类相关的类变量,和实例无关。他的生命周期是很长的,贯穿于app的启动到关闭。因此只要用一个static引用一个大对象,就可以泄漏了!举个例子:
static Activity activity;
这是最简单粗暴的持有一个activity的引用,这样这个activity退出之后对象并没有被销毁。
static Activity activity;
这是最简单粗暴的持有一个activity的引用,这样这个activity退出之后对象并没有被销毁。
解决方法(以activity引用为例子)
思路:泄漏是因为持有了activity引用导致无法被销毁,那么只有两个选择:及时取消引用,或者让这个引用多待一会,但是该gc的时候就销毁。
- 我们在代码中能不用static变量持有contxt就不用,非要用就用weak引用。
- 对于内部类,尽量用静态内部类,这样就不会持有外部类引用。如果需要外部类引用做一些事,就手动赋给一个weak引用。
- 对于匿名内部类,不要图简单方便,实在不行就乖乖的写成外部类。
- 异步操作,尽量用可以方便管理的,比如rxJava,而不是用老古董AsyncTask了。非要用也最好加一个终止条件,在退出Activity时就该结束了。
- 在用rx时,可以在subscribe()的时候获取到Subscripeion,在不用的时候手动unSubscribe(),或者直接bind()到Activity的生命周期上,比如使用RxActivity管理。
- 在使用handler时,记得在activity的onDestroy()中加上remove()
- 在获取到某些资源时,使用完记得释放
- 在用到一些大对象比如Bitmap啊什么的,要记得回收
- 最后,在使用各种第三方库或者系统服务的时候还要记得有注册或绑定就要有解除注册、解绑定。
- 对于内部类,尽量用静态内部类,这样就不会持有外部类引用。如果需要外部类引用做一些事,就手动赋给一个weak引用。
- 对于匿名内部类,不要图简单方便,实在不行就乖乖的写成外部类。
- 异步操作,尽量用可以方便管理的,比如rxJava,而不是用老古董AsyncTask了。非要用也最好加一个终止条件,在退出Activity时就该结束了。
- 在用rx时,可以在subscribe()的时候获取到Subscripeion,在不用的时候手动unSubscribe(),或者直接bind()到Activity的生命周期上,比如使用RxActivity管理。
- 在使用handler时,记得在activity的onDestroy()中加上remove()
- 在获取到某些资源时,使用完记得释放
- 在用到一些大对象比如Bitmap啊什么的,要记得回收
- 最后,在使用各种第三方库或者系统服务的时候还要记得有注册或绑定就要有解除注册、解绑定。
经典案例
没有实现hasCode和equals方法的Key类在HashMap中保存的情况
Java中,造成内存泄露的原因有很多种。典型的例子是一个没有实现hasCode和equals方法的Key类在HashMap中保存的情况。最后会生成很多重复的对象。所有的内存泄露最后都会抛出OutOfMemoryError异常
ThreadLocal内存泄漏问题
ThreadLocal放入123,线程结束以后时会有内存泄漏问题,他是服务器本地线程,线程用完以后会把他放回线程池,线程再打过来以后是可以拿到的123的,而且本身是强引用。
总结:
由于Thread中包含变量ThreadLocalMap,因此ThreadLocalMap与Thread的生命周期是一样长,如果都没有手动删除对应key,都会导致内存泄漏。
但是使用弱引用可以多一层保障:弱引用ThreadLocal不会内存泄漏,对应的value在下一次ThreadLocalMap调用set(),get(),remove()的时候会被清除。
因此,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。
由于Thread中包含变量ThreadLocalMap,因此ThreadLocalMap与Thread的生命周期是一样长,如果都没有手动删除对应key,都会导致内存泄漏。
但是使用弱引用可以多一层保障:弱引用ThreadLocal不会内存泄漏,对应的value在下一次ThreadLocalMap调用set(),get(),remove()的时候会被清除。
因此,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。
ThreadLocal正确的使用方法:
- 每次使用完ThreadLocal都调用它的remove()方法清除数据
- 将ThreadLocal变量定义成private static,这样就一直存在ThreadLocal的强引用,也就能保证任何时候都能通过ThreadLocal的弱引用访问到Entry的value值,进而清除掉 。
- 每次使用完ThreadLocal都调用它的remove()方法清除数据
- 将ThreadLocal变量定义成private static,这样就一直存在ThreadLocal的强引用,也就能保证任何时候都能通过ThreadLocal的弱引用访问到Entry的value值,进而清除掉 。
面试高频问题
jvm中垃圾回收器介绍下:cms和g1
把两个说清楚基本也就可以了。
先说下cms:CMS收集器是并发收集器,基本上实现了让垃圾回收线程和用户线程同时工作,整个过程分为四步:初始标记、并发标记、重新标记、并发清除;主要优点是并发收集、低停顿;但是他也有缺点,对CPU资源敏感(并发线程,CPU核数越高性能越好),也无法处理浮动的垃圾并且收集结束时会有大量空间碎片产生。
再说下G1:在极高概率满足 GC 低停顿的同时,还具备高吞吐量;整个过程也分为四步:初始标记、并发标记、最终标记、筛选回收;G1 收集器除了建立一个可预测的停顿时间模型,同时还在后台还维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的区域,这种方式可以保证G1在有限时间内尽可能高的提高回收效率。
先说下cms:CMS收集器是并发收集器,基本上实现了让垃圾回收线程和用户线程同时工作,整个过程分为四步:初始标记、并发标记、重新标记、并发清除;主要优点是并发收集、低停顿;但是他也有缺点,对CPU资源敏感(并发线程,CPU核数越高性能越好),也无法处理浮动的垃圾并且收集结束时会有大量空间碎片产生。
再说下G1:在极高概率满足 GC 低停顿的同时,还具备高吞吐量;整个过程也分为四步:初始标记、并发标记、最终标记、筛选回收;G1 收集器除了建立一个可预测的停顿时间模型,同时还在后台还维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的区域,这种方式可以保证G1在有限时间内尽可能高的提高回收效率。
jvm如何进行垃圾回收
先找到哪些允许被回收的-->开始回收
第一步:哪些对象是垃圾:
1,引用计数法:通过对引用的遍历,找到对应的实例,让对应的实例计数加1,如果引用取消,或者指向null,实例的引用减1 。把找到的引用都遍历一遍之后,如果发现有对象实例的计数是0。那么这个对象 就是垃圾对象了。在通过垃圾回收算法对其进行 回收即可。
缺点:想想一下,有两个类,互相引用,也就是A对象的实例(也就是对象的全局变量)是一个指向B对象的引用,B对象实例是一个指向A对象的引用。那么这两个对象的引用计数,永远不可能是0 。也就不可能对其进行回收了。
2,可达性分析法:这个算法类似于树的遍历,学过数据结构的小伙伴应该会好理解。简单来说,按照一定的规则说明那些可以作为一个根节点(GC root),然后以这些根节点去访问其引用的对象,被访问的对象又会有其他对象的引用。想象一下,是不是像极了树的遍历。这个路径称作引用链,但凡是在引用链上的对象,都是可用的。注意,引用连的起始点都是GC root 哦。虽然有其他对象存在类似于引用链的结构,但是,起始点不是GC root的那一些,都是垃圾,可以被回收的。
GC root哪些对象会被认为是root;
GC root的查找规则:java栈中的引用,方法区中的静态属性(静态变量 + 静态常量),方法区中常量引用的对象(方法区中有个结构 叫做 常量池 ,存储的一部分是常量),本地方法(线程独占区中有个结构叫做 本地方法栈)。
大对象直接分配到老年区:大对象的就是,对象里面有很大数组或者很大的字符串;所以在代码里尽量避免大数组和大字符串的存在,容易长期占用老年区空间,容易触发老年区Full GC,频繁的fullGC会出现cpu内存飙升的问题,毕竟它是异步清理占用CPU线程。
一般情况下,都是使用的 可达性分析法去查找垃圾类实例。
总结(面试回答这些基本足够):
我们常说的JVM垃圾回收主要指堆的垃圾回收问题,堆又分为新生代和老年代,
新生代分为伊甸园区和幸存区,幸存区分为from和to区。放入对象时,对象先会来到伊甸区,如果伊甸区的剩余内存大小可以放下,就直接放到伊甸区,如果伊甸内存区不够,就会进行minor GC 将伊甸园区的对象进行回收,并且将存活的对象放置from区;
如果对象来到伊甸园区,进行minor GC之后对象仍然放不下,又到幸存区也放不下,就会进入老年代,直到老年代放不下的时候,先会进行一次major gc ,如果进行完之后,还是内存不够,就会进行full GC ,
full GC 会将新生代和老年代都会进行回收,并且会使其他的进程全部停止,如果进行full GC 之后,内存还是不够的时候,就会抛出异常OOM;
新生代用的是复制算法;老年代用的算法是标记整理算法,具体要看我们选用哪种收集器(如果面试官问那就把上面的CMS和Gone说下)。
第一步:哪些对象是垃圾:
1,引用计数法:通过对引用的遍历,找到对应的实例,让对应的实例计数加1,如果引用取消,或者指向null,实例的引用减1 。把找到的引用都遍历一遍之后,如果发现有对象实例的计数是0。那么这个对象 就是垃圾对象了。在通过垃圾回收算法对其进行 回收即可。
缺点:想想一下,有两个类,互相引用,也就是A对象的实例(也就是对象的全局变量)是一个指向B对象的引用,B对象实例是一个指向A对象的引用。那么这两个对象的引用计数,永远不可能是0 。也就不可能对其进行回收了。
2,可达性分析法:这个算法类似于树的遍历,学过数据结构的小伙伴应该会好理解。简单来说,按照一定的规则说明那些可以作为一个根节点(GC root),然后以这些根节点去访问其引用的对象,被访问的对象又会有其他对象的引用。想象一下,是不是像极了树的遍历。这个路径称作引用链,但凡是在引用链上的对象,都是可用的。注意,引用连的起始点都是GC root 哦。虽然有其他对象存在类似于引用链的结构,但是,起始点不是GC root的那一些,都是垃圾,可以被回收的。
GC root哪些对象会被认为是root;
GC root的查找规则:java栈中的引用,方法区中的静态属性(静态变量 + 静态常量),方法区中常量引用的对象(方法区中有个结构 叫做 常量池 ,存储的一部分是常量),本地方法(线程独占区中有个结构叫做 本地方法栈)。
大对象直接分配到老年区:大对象的就是,对象里面有很大数组或者很大的字符串;所以在代码里尽量避免大数组和大字符串的存在,容易长期占用老年区空间,容易触发老年区Full GC,频繁的fullGC会出现cpu内存飙升的问题,毕竟它是异步清理占用CPU线程。
一般情况下,都是使用的 可达性分析法去查找垃圾类实例。
总结(面试回答这些基本足够):
我们常说的JVM垃圾回收主要指堆的垃圾回收问题,堆又分为新生代和老年代,
新生代分为伊甸园区和幸存区,幸存区分为from和to区。放入对象时,对象先会来到伊甸区,如果伊甸区的剩余内存大小可以放下,就直接放到伊甸区,如果伊甸内存区不够,就会进行minor GC 将伊甸园区的对象进行回收,并且将存活的对象放置from区;
如果对象来到伊甸园区,进行minor GC之后对象仍然放不下,又到幸存区也放不下,就会进入老年代,直到老年代放不下的时候,先会进行一次major gc ,如果进行完之后,还是内存不够,就会进行full GC ,
full GC 会将新生代和老年代都会进行回收,并且会使其他的进程全部停止,如果进行full GC 之后,内存还是不够的时候,就会抛出异常OOM;
新生代用的是复制算法;老年代用的算法是标记整理算法,具体要看我们选用哪种收集器(如果面试官问那就把上面的CMS和Gone说下)。
面试题
IO
换句话说 , 如果是文件读写完的同时缓冲区刚好装满 , 那么缓冲区会把里面的数据朝目标文件自动进行读或写 , 这种时候你直接调用close()方法不会出现问题 ; 但是如果文件在读写完成时 , 缓冲区没有装满 , 就直接调用close()方法 , 这个时候装在缓冲区的数据就不会自动的朝目标文件进行读或写 , 从而造成缓冲区中的这部分数据丢失 , 所以这个是时候就需要在close()之前先调用flush()方法 , 手动使缓冲区数据读写到目标文件. 举个例子: 如果一个文件大小是20kb , 我们的缓冲区大小是15kb , 如果Close()方法之前没有先调用flush()方法 , 那么这个时候剩余的5kb数据就会丢失 .
子主题
分支主题
收藏
收藏
0 条评论
下一页