码农戏码

新生代农民工的自我修养


  • 首页

  • 归档

  • 标签

  • 关于

  • 在线工具

  • 搜索

Hashmap源码解析

发表于 2017-04-06 | 分类于 java
字数统计: 4.6k 字数 | 阅读时长 ≈ 19 分钟

前言

做什么都怕进入狗咬尾巴的怪圈,上次看hashmap源码还是2012年,这次出去面试时被问到了hashmap的问题,整体思路还是记得的,巴拉巴拉一堆。回来再看一下源码,温习一下

想要了解hashmap,就得先知道一下他的数据结构理论

哈希数据结构

哈希表(Hash table,也叫散列表),是根据key而直接进行访问的数据结构。也就是说,它通过把key映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

哈希表的做法其实很简单,就是把key通过一个固定的算法函数即所谓的哈希函数转换成一个整型数字,然后就将该数字对数组长度进行取余,取余结果就当作数组的下标,将value存储在以该数字为下标的数组空间里。

哈希表是一个在时间和空间上做出权衡的经典例子。如果没有内存限制,那么可以直接将键作为数组的索引。那么所有的查找时间复杂度为O(1);如果没有时间限制,那么我们可以使用无序数组并进行顺序查找,这样只需要很少的内存。哈希表使用了适度的时间和空间来在这两个极端之间找到了平衡。只需要调整哈希函数算法即可在时间和空间上做出取舍。

哈希函数

哈希查找第一步就是使用哈希函数将键映射成索引。这种映射函数就是哈希函数。如果我们有一个保存0-M数组,那么我们就需要一个能够将任意键转换为该数组范围内的索引(0~M-1)的哈希函数

R.W.Floyed给出的衡量散列思想的三个标准:

  1. 一个好的hash算法的计算应该是非常快的
  2. 一个好的hash算法应该是冲突极小化, 如果存在冲突,应该是冲突均匀化。

哈希冲突

通过哈希函数,我们可以将键转换为数组的索引(0-M-1),但是对于两个或者多个键具有相同索引值的情况,我们需要有一种方法来处理这种冲突。

拉链法

一种比较直接的办法就是,将大小为M 的数组的每一个元素指向一个条链表,链表中的每一个节点都存储散列值为该索引的键值对,这就是拉链法

image

该方法的基本思想就是选择足够大的M,使得所有的链表都尽可能的短小,以保证查找的效率。对采用拉链法的哈希实现的查找分为两步,首先是根据散列值找到等一应的链表,然后沿着链表顺序找到相应的键。

实现基于拉链表的散列表,目标是选择适当的数组大小M,使得既不会因为空链表而浪费内存空间,也不会因为链表太而在查找上浪费太多时间。拉链表的优点在于,这种数组大小M的选择不是关键性的,如果存入的键多于预期,那么查找的时间只会比选择更大的数组稍长,另外,我们也可以使用更高效的结构来代替链表存储。如果存入的键少于预期,索然有些浪费空间,但是查找速度就会很快。所以当内存不紧张时,我们可以选择足够大的M,可以使得查找时间变为常数,如果内存紧张时,选择尽量大的M仍能够将性能提高M倍。

线性探测法

线性探测法是开放寻址法解决哈希冲突的一种方法,基本原理为,使用大小为M的数组来保存N个键值对,其中M>N,我们需要使用数组中的空位解决碰撞冲突。如下图所示:

image

对照前面的拉链法,在该图中,”Ted Baker” 是有唯一的哈希值153的,但是由于153被”Sandra Dee”占用了。而原先”Snadra Dee”和”John Smith”的哈希值都是152的,但是在对”Sandra Dee”进行哈希的时候发现152已经被占用了,所以往下找发现153没有被占用,所以存放在153上,然后”Ted Baker”哈希到153上,发现已经被占用了,所以往下找,发现154没有被占用,所以值存到了154上。

开放寻址法中最简单的是线性探测法:当碰撞发生时即一个键的散列值被另外一个键占用时,直接检查散列表中的下一个位置即将索引值加1,这样的线性探测会出现三种结果:

  1. 命中,该位置的键和被查找的键相同
  2. 未命中,键为空
  3. 继续查找,该位置和键被查找的键不同。

线性探查(Linear Probing)方式虽然简单,但是有一些问题,它会导致同类哈希的聚集。在存入的时候存在冲突,在查找的时候冲突依然存在。

hashmap 源码分析

hashmap原理角度看就是基于哈希数据结构的一种实现,解决冲突的办法是使用拉链法

从源码基于JDK1.7看

1
2
3
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable

HashMap是基于哈希表的Map接口的非同步实现,它提供所有可选的映射操作,并允许使用null键和null值。
此集合不保证映射的顺序,特别不保证其顺序永久不变。

初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);

this.loadFactor = loadFactor;
threshold = initialCapacity;
init();
}

两个重要的初始化参数,[初始容量,负载因子]

这个JDK7大版本里面的小版本对hashmap改动好好几次。

这个源码是基于JDK7.0_80的。有些版本在构造函数里面就确保容量是2的N次方,但这个版本没有,很简单的赋值

初始容量16,负载因子0.75

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;

2的N次方

很多算法书上都认为想要降低key冲突,最好容量为素数。为什么java却取了个2的N次方呢。

为了将各元素的hashCode保存至长度为Length的key数组中,一般采用取模的方式,即index = hashCode % Length。不可避免的,存在多个不同对象的hashCode被安排在同一位置,这就是我们平时所谓的“冲突”。
如果仅仅是考虑元素均匀化与冲突极小化,似乎应该将Length取为素数(尽管没有明显的理论来支持这一点,但数学家们通过大量的实践得出结论,对素数取模的产生结果的无关性要大于其它数字)。为此,Craig Larman and Rhett Guthrie《Java Performence》中对此也大加抨击。

为了弄清楚这个问题,Bruce Eckel(Thinking in JAVA的作者)专程采访了java.util.hashMap的作者Joshua Bloch,此设计的原因
取模运算在包括Java在内的大多数语言中的效率都十分低下,而当除数为2的N次方时,取模运算将退化为最简单的位运算,其效率明显提升(按照Bruce Eckel给出的数据,大约可以提升5~8倍)

1
2
3
4
5
6
7
/**
* Returns index for hash code h.
*/
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}

看下图,左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。两组的hashcode为8和9,但是很明显,当它们和1110“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!

image

put操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with <tt>key</tt>, or
* <tt>null</tt> if there was no mapping for <tt>key</tt>.
* (A <tt>null</tt> return can also indicate that the map
* previously associated <tt>null</tt> with <tt>key</tt>.)
*/
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}

modCount++;
addEntry(hash, key, value, i);
return null;
}

当table == EMPTY_TABLE时,先填充table

1
2
3
4
5
6
7
8
9
10
11
/**
* Inflates the table.
*/
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
int capacity = roundUpToPowerOf2(toSize);

threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}

这儿看到了2的N次方,没有跟之前一样使用while循环

1
2
3
int capacity = 1;  
while (capacity < initialCapacity)
capacity <<= 1;

threshold的值=容量*负载因子

看下roundUpToPowerOf2()

1
2
3
4
5
6
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}

主要是Integer.highestOneBit

1
2
3
4
5
6
7
8
9
public static int highestOneBit(int i) {
// HD, Figure 3-1
i |= (i >> 1);
i |= (i >> 2);
i |= (i >> 4);
i |= (i >> 8);
i |= (i >> 16);
return i - (i >>> 1);
}

这个方法单纯的使用位运算,性能自然提高
解析下这个方法,这个方法的作用是求构成一个整数的最大的位所代表的整数的值
接下来举个简单的例子,128来讲二进制是1000 0000。下面以他为例子算下:
移1位
1000 0000
0100 0000
|————-
移2位
1100 0000
0011 0000
|————
移4位
1111 0000
0000 1111
|————
移8位
1111 1111
0000 0000
|————
移动16位
1111 1111
0000 0000
|————
1111 1111
最终的结果如你所看到的,后面的位全部填充为1,把后面的位全部减掉就得到了最高的位代表的整数。

回到put方法,如果key==null,putForNullKey(value);说明hashmap支持key为null

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* Offloaded version of put for null keys
*/
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}

这个逻辑比较简单

  1. 第一步,如果找到之前有key为null的key,进行一下value替换,返回oldValue
  2. 如果没有为null的key,那进行addEntry() 添加元素的主要方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    /**
    * Adds a new entry with the specified key, value and hash code to
    * the specified bucket. It is the responsibility of this
    * method to resize the table if appropriate.
    *
    * Subclass overrides this to alter the behavior of put method.
    */
    void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
    resize(2 * table.length);
    hash = (null != key) ? hash(key) : 0;
    bucketIndex = indexFor(hash, table.length);
    }

    createEntry(hash, key, value, bucketIndex);
    }

新添加元素时,先看下有没有达到threshold && bucketIndex元素不为null

如果成立,那就需要resize,这是个耗性能的操作,所以在初始化时,一般计算好元素多少,给一个合适的初始容量

按照上面的条件,一个合适的容量应该这样计算了

我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.751000 < 1000, 也就是说为了让0.75 size > 1000, 我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。

先看createEntry

1
2
3
4
5
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}

这个很简单,新建一个Entry指向bucketIndex,老元素e,被指向新元素的next。

这儿也看出hashmap是使用的拉链法

看Entry,就是一个链表,一个元素接着另一个元素

1
2
3
4
5
6
7
8
9
10
11
12
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;

Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}

resize方法

耗性能的地方,重新散列,看看这个方法的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//在put方法里,当容量达到threshold时,进行双倍的扩容。
resize(2 * table.length);


void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}

Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}

如果容量已经达到MAX_VALUE,那就不扩容了,只能处理冲突

transfer(),这是核心方法,把以前的元素,按新的capacity,计算的hashCode,重新放置元素。

这儿看到e.next=newTable[i],明显使用的是链表头部插入法

get方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);

return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}

int hash = (key == null) ? 0 : hash(key);
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}

get很好理解了,跟算法一致,先hash找bucket,如果有冲突的元素,再使用equals来判定

hashSeed

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* A randomizing value associated with this instance that is applied to
* hash code of keys to make hash collisions harder to find. If 0 then
* alternative hashing is disabled.
*/
transient int hashSeed = 0;

/**
* Initialize the hashing mask value. We defer initialization until we
* really need it.
*/
final boolean initHashSeedAsNeeded(int capacity) {
boolean currentAltHashing = hashSeed != 0;
boolean useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean switching = currentAltHashing ^ useAltHashing;
if (switching) {
hashSeed = useAltHashing
? sun.misc.Hashing.randomHashSeed(this)
: 0;
}
return switching;
}

initHashSeedAsNeeded在inflateTable中被调用过,这个hashSeed到底有什么用?

hashSeed就好像我们加密时使用的密钥,在StackOverfolw上是这样描述hashSeed的:

The seed parameter is a means for you to randomize the hash function. You should provide the same seed value for all calls to the hashing function in the same application of the hashing function. However, each invocation of your application (assuming it is creating a new hash table) can use a different seed, e.g., a random value.

Why is it provided?

One reason is that attackers may use the properties of a hash function to construct a denial of service attack. They could do this by providing strings to your hash function that all hash to the same value destroying the performance of your hash table. But if you use a different seed for each run of your program, the set of strings the attackers must use changes.

虽然seed近似随机,但在同一个HashMap中必须保证每次的计算Hash值的时候使用的同一个seed,也就相当于保证我们在一个密码系统中加密时,使用同一个密钥。同时使用seed可以抵御攻击,因为每个应用的seed都会一样。

对于安全方面的了解几乎是0,有机会再学习了。

ConcurrentModificationException

hashmap不是线程安全的

1
2
3
4
5
6
7
8
9
10
 private abstract class HashIterator<E> implements Iterator<E> {
Entry<K,V> next; // next entry to return
int expectedModCount; // For fast-fail
int index; // current slot
Entry<K,V> current; // current entry


final Entry<K,V> nextEntry() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();

这个内部遍历类使用了Fail-Fast机制

1
2
3
4
5
6
7
8
/**
* The number of times this HashMap has been structurally modified
* Structural modifications are those that change the number of mappings in
* the HashMap or otherwise modify its internal structure (e.g.,
* rehash). This field is used to make iterators on Collection-views of
* the HashMap fail-fast. (See ConcurrentModificationException).
*/
transient int modCount;

modCount为HashMap的一个实例变量,并且被声明为volatile,表示任何线程都可以看到该变量被其它线程修改的结果

(根据JVM内存模型的优化,每一个线程都会存一份自己的工作内存,此工作内存的内容与本地内存并非时时刻刻都同步,因此可能会出现线程间的修改不可见的问题)

使用Iterator开始迭代时,会将modCount的赋值给expectedModCount,在迭代过程中,通过每次比较两者是否相等来判断HashMap是否在内部或被其它线程修改

在并发条件下,还是要使用并发包下的ConcurrentHashmap,有时间也得写一写

总结

(1) 扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。

(2) 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。

(3) HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。

参考资料

浅谈算法和数据结构: 十一 哈希表

单链表—java实现

HashMap的存取之美

游戏小传序

发表于 2017-04-01
字数统计: 476 字数 | 阅读时长 ≈ 1 分钟

序言

在游戏行业足足待了八年了。从大学毕业就开始开发游戏。一直到现在还在一线岗位。

从小小的开发,到掌握后端全局的主程;从胎死腹中的产品,到月流水过亿的大作。

游戏人太苦,996工作制是正常的,凌晨下班也是常有的,为什么可以坚持,可能是心中都有个游戏梦,要大成,要大作。但每次都是充满期待,结果却无声奈何。想想那些从别的行业转来做游戏的同事,一个一个期望变成失落,坚持了几年,又都失望地离开了游戏行业,而我是幸运的,至少经历了大作,经过了一款月流水过亿的产品。

人常说七年就是一辈子,那我这算在工作经历上过完了一辈子,下辈子怎么过呢?不清楚。但至少不能再重复上辈子

对于一辈子想有个总结,开始想写本书,至少是个系列《游戏开发实战》,写的过程又是重学的过程,而之前定的一周一篇技术文章的目标总是不能执行,因为时间不够,工作很忙,要写一篇好的技术文章很废时间。所以不想再以技术文章为系列去写,写技术文章太枯燥,由浅入深,面面俱到,太累!

所以这次就以时间为轴线,以技术内容为补充,记录一下我从小鸟变成一个老兵的成长过程,在此过程中遇到的一些问题,思考心得。

ThreadLocal解析

发表于 2017-03-15
字数统计: 3.3k 字数 | 阅读时长 ≈ 14 分钟

前言

最近对于职业规划做了些思考,时间不等人,时机不等人;又是金三银四的好时机,今天当面试官虐人,明天被面试被人虐,相互伤害,爱恨情仇,一切都是缘分。

一份简历,一两张A4纸,就要体现出一个人的真实水平,没点功力,还真是难。

技术人都比较实在,不明白之前,感觉很高深,有难点,学了之后必有进步;但在搞过之后,发现不过如此,感觉都是理所当然,简单如是,不好意思跟人讲研究了什么,实在是太简单;

正因为有如此思维,所以在写简历时无处下笔,感觉没什么优势可写,毫无亮点。
要么就是简单写写项目经历,要么对着招聘简介看看,摘抄一些了

不管怎么写,还是要写出自己的特长,把自己拿手的写出来,少不可怕,只要写出来的不被面试官难倒,就算成功。

当然也不能为了体现多面手,乱写,熟悉XX,精通OO,了解MM,掌握GG;到面试时,被问得打脸就不好了。

面试时态度端正也很重要,正面回答问题,有时其实对问题答案掌握不好,回避问题甚至绕弯弯,当然这还是看度的,不可绕太多,行家一出手就知有没有,诚恳点,知道多少说多少,知之为知之。


面试官,我当得也不好,层次不清,岗位定位不清,有时为了招些优秀的,会问些不合岗位的问题,问得过难;

还是要好好挖掘候选人简历,毕竟简历是展现的第一窗口,技术人书写能力差,不能很好的推销自己,所以还是要从简历中挖掘亮点,有时很精通,但写出来的很笼统,需要面试官去发现亮点了

ThreadLocal的意义

这个类,好早就有了,JDK1.2就出现了。有时也会用一用,但他的作用是什么,很难表达了,难以表达,不能形成文字,说明了解的深度不够。

ThreadLocal为解决多线程程序的并发问题提供了一种新的思路;

ThreadLocal的目的是为了解决多线程访问资源时的共享问题。

这基本上搜索到的threadlocal文章开头都是这样写的。

然,谎言说多了就成了真理。

但在JDK文档里面

该类提供了线程局部 (thread-local)变量。
这些变量不同于它们的普通对应物,因为访问某个变量(通过其 get 或 set 方法)的每个线程都有自己的局部变量,它独立于变量的初始化副本。
ThreadLocal 实例通常是类中的 private static 字段,它们希望将状态与某一个线程(例如,用户 ID 或事务 ID)相关联。

ThreadLocal的作用是提供线程内的局部变量,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或者组件之间一些公共变量的传递的复杂度。

ThreadLocal和多线程并发没有什么关系。ThreadLocal模式是为了解决单线程内的跨类跨方法调用的

ThreadLocal不是用来解决对象共享访问问题的,而是为了处理在多线程环境中,某个方法处理一个业务,需要递归依赖其他方法时,而要在这些方法中共享参数的问题。

例如有方法a(),在该方法中调用了方法b(),而在b方法中又调用了方法c(),即a–>b—>c,如果a,b,c都需要使用用户对象,那么我们常用做法就是a(User user)–>b(User user)—c(User user)。

但是如果使用ThreadLocal我们就可以用另外一种方式解决:
在某个接口中定义一个静态的ThreadLocal 对象,
例如 public static ThreadLocal threadLocal=new ThreadLocal ();
然后让a,b,c方法所在的类假设是类A,类B,类C都实现1中的接口
在调用a时,使用A.threadLocal.set(user) 把user对象放入ThreadLocal环境
这样我们在方法a,方法b,方法c可以在不用传参数的前提下,在方法体中使用threadLocal.get()方法就可以得到user对象。

上面的类A,类B ,类C就可以分别对应我们做web开发时的 web层的Action—>业务逻辑层的Service–>数据访问层的DAO,当我们要在这三层中共享参数时,那么我们就可以使用ThreadLocal 了。

在使用hibernate中常见的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class HibernateUtil {
private static Log log = LogFactory.getLog(HibernateUtil.class);
private static final SessionFactory sessionFactory; //定义SessionFactory

static {
try {
// 通过默认配置文件hibernate.cfg.xml创建SessionFactory
sessionFactory = new Configuration().configure().buildSessionFactory();
} catch (Throwable ex) {
log.error("初始化SessionFactory失败!", ex);
throw new ExceptionInInitializerError(ex);
}
}

//创建线程局部变量session,用来保存Hibernate的Session
public static final ThreadLocal session = new ThreadLocal();

/**
* 获取当前线程中的Session
* @return Session
* @throws HibernateException
*/
public static Session currentSession() throws HibernateException {
Session s = (Session) session.get();
// 如果Session还没有打开,则新开一个Session
if (s == null) {
s = sessionFactory.openSession();
session.set(s); //将新开的Session保存到线程局部变量中
}
return s;
}

public static void closeSession() throws HibernateException {
//获取线程局部变量,并强制转换为Session类型
Session s = (Session) session.get();
session.set(null);
if (s != null)
s.close();
}
}

Threadlocal源码

这个类有以下方法:

  1. get():返回当前线程拷贝的局部线程变量的值。
  2. initialValue():返回当前线程赋予局部线程变量的初始值。
  3. remove():移除当前线程赋予局部线程变量的值。
  4. set(T value):为当前线程拷贝的局部线程变量设置一个特定的值。

get()方法

1
2
3
4
5
6
7
8
9
10
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null)
return (T)e.value;
}
return setInitialValue();
}

根据当前线程,拿到ThreadLocalMap,通过当前threadloal对象取到value.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}

ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}

void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

Thread.java 里面

ThreadLocalMap是Thread的属性

1
2
3
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

这个还跟之前的理解不太一样,以为是以当前threadId为key,取到值

数据结构是这样的:

threadlocal<threadId,value>

事实上结构是这样的:

thread <-> threadlocalmap<threadlocal,value>

每个Thread维护一个ThreadLocalMap映射表,这个映射表的key是ThreadLocal实例本身,value是真正需要存储的Object。

这样设计的主要有以下几点优势:

  1. 这样设计之后每个Map的Entry数量变小了:之前是Thread的数量,现在是ThreadLocal的数量,能提高性能,据说性能的提升不是一点两点
  2. 当 Thread销毁之后对应的ThreadLocalMap也就随之销毁了,能减少内存使用量。

内存泄露

有些人认为在使用此类时,容易出现OOM

image

如上图,ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用引用他,那么系统gc的时候,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:

Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value

永远无法回收,造成内存泄露。

进一步分析一下ThreadlocalMap

1
2
3
4
5
6
7
8
9
10
11
/**
* ThreadLocalMap is a customized hash map suitable only for
* maintaining thread local values. No operations are exported
* outside of the ThreadLocal class. The class is package private to
* allow declaration of fields in class Thread. To help deal with
* very large and long-lived usages, the hash table entries use
* WeakReferences for keys. However, since reference queues are not
* used, stale entries are guaranteed to be removed only when
* the table starts running out of space.
*/
static class ThreadLocalMap {

根据注释和代码,发现ThreadLocalMap并没有使用HashMap,而是重新实现了一个map
,里面放的Entry

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* The entries in this hash map extend WeakReference, using
* its main ref field as the key (which is always a
* ThreadLocal object). Note that null keys (i.e. entry.get()
* == null) mean that the key is no longer referenced, so the
* entry can be expunged from table. Such entries are referred to
* as "stale entries" in the code that follows.
*/
static class Entry extends WeakReference<ThreadLocal> {
/** The value associated with this ThreadLocal. */
Object value;

Entry(ThreadLocal k, Object v) {
super(k);
value = v;
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
/**
* Construct a new map initially containing (firstKey, firstValue).
* ThreadLocalMaps are constructed lazily, so we only create
* one when we have at least one entry to put in it.
*/
ThreadLocalMap(ThreadLocal firstKey, Object firstValue) {
//默认16长度
table = new Entry[INITIAL_CAPACITY];
//hashcode取模,得到坑位
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}

/**
* Set the resize threshold to maintain at worst a 2/3 load factor.
*/
private void setThreshold(int len) {
threshold = len * 2 / 3;
}

private void set(ThreadLocal key, Object value) {

// We don't use a fast path as with get() because it is at
// least as common to use set() to create new entries as
// it is to replace existing ones, in which case, a fast
// path would fail more often than not.
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len - 1);

for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
ThreadLocal k = e.get();

if (k == key) {
e.value = value;
return;
}
//key在gc时,会被回收,变成null
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}

tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
private void replaceStaleEntry(ThreadLocal key, Object value, int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;

// Back up to check for prior stale entry in current run.
// We clean out whole runs at a time to avoid continual
// incremental rehashing due to garbage collector freeing
// up refs in bunches (i.e., whenever the collector runs).
int slotToExpunge = staleSlot;
//替换掉之前有key=null的
for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;

// Find either the key or trailing null slot of run, whichever
// occurs first
for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
ThreadLocal k = e.get();

// If we find key, then we need to swap it
// with the stale entry to maintain hash table order.
// The newly stale slot, or any other stale slot
// encountered above it, can then be sent to expungeStaleEntry
// to remove or rehash all of the other entries in run.
if (k == key) {
e.value = value;

tab[i] = tab[staleSlot];
tab[staleSlot] = e;

// Start expunge at preceding stale entry if it exists
if (slotToExpunge == staleSlot)
slotToExpunge = i;
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}

// If we didn't find stale entry on backward scan, the
// first stale entry seen while scanning for key is the
// first still present in the run.
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}

// If key not found, put new entry in stale slot
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);

// If there are any other stale entries in run, expunge them
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

在这个过程中遇到的key为null的Entry都会被擦除,那么Entry内的value也就没有强引用链,自然会被回收。将key为null的这些Entry都删除,防止内存泄露。

但是光这样还是不够的,上面的设计思路依赖一个前提条件:
要调用ThreadLocalMap的getEntry函数或者set函数。这当然是不可能任何情况都成立的,所以很多情况下需要使用者手动调用ThreadLocal的remove函数,手动删除不再需要的ThreadLocal,防止内存泄露。
所以JDK建议将ThreadLocal变量定义成private static的,
这样的话ThreadLocal的生命周期就更长,由于一直存在ThreadLocal的强引用,所以ThreadLocal也就不会被回收,也就能保证任何时候都能根据ThreadLocal的弱引用访问到Entry的value值,然后remove它,防止内存泄露

为什么使用弱引用

To help deal with very large and long-lived usages, the hash table entries use WeakReferences for keys.
为了应对非常大和长时间的用途,哈希表使用弱引用的 key

  • key 使用强引用:引用的ThreadLocal的对象被回收了,但是ThreadLocalMap还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收,导致Entry内存泄漏。
  • key 使用弱引用:引用的ThreadLocal的对象被回收了,由于ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。value在下一次ThreadLocalMap调用set,get,remove的时候会被清除

比较两种情况,我们可以发现:由于ThreadLocalMap的生命周期跟Thread一样长,如果都没有手动删除对应key,都会导致内存泄漏,但是使用弱引用可以多一层保障:弱引用ThreadLocal不会内存泄漏,对应的value在下一次ThreadLocalMap调用set,get,remove的时候会被清除。

因此,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。

总结

ThreadLocal的作用是提供线程内的局部变量,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或者组件之间一些公共变量的传递的复杂度。

  • 不是为了解决多线程共享同步之类的作用

  • ThreadLocal变量定义成private static,不要定义成局部变量

参考资料

http://www.iteye.com/topic/617368

http://qifuguang.me/2015/09/02/[Java%E5%B9%B6%E5%8F%91%E5%8C%85%E5%AD%A6%E4%B9%A0%E4%B8%83]%E8%A7%A3%E5%AF%86ThreadLocal/

JIT优化之道

发表于 2017-03-02
字数统计: 2.9k 字数 | 阅读时长 ≈ 11 分钟

碎碎念

《JIT优化之道》是去年在公司的一次分享,对于公司组织分享我是赞同又不赞同,怎么讲呢?

技术分享当然是好的,这是一个双赢,分享者教学相长,而收听者也能更快的了解进步。

但以前在原先的公司也做过些类事情,但没有想象的好,大家对分享主题的探索也只限于在分享时间段内,过后很少有人,几乎没人去做进一步的探索。填鸭式的学习效果甚微。后来只涉及一些项目中使用到的知识点,让项目中人去发现项目中的一些亮点,盲区

聪明人从旁人的错误中吸取教训,愚笨人则从自身的错误中吸取教训,有多少聪明人呢?
不经历风雨又怎么见彩虹?

JIT主要关注三个点

  1. JIT是什么
  2. JIT的原理
  3. JIT的意义

JIT是什么

JIT是just in time,即时编译器;使用该技术,能够加速java程序的执行速度

image

编译器

image

Java编译器总的来说分为

  1. 前端编译器
  2. JIT(just in time compiler)编译器
  3. AOT(Ahead Of Time Compiler)编译器

前端编译器: 将Java文件编译为class文件的编译器,目前主要有以下两个,
Sun提供的Javac 和Eclipse JDT中的增量式编译器(ECJ)

JIT编译器: 虚拟机后端运行期编译器,把字节码转换为机器码的过程。
HotSpot Vm中提供的C1, C2编译器

AOT编译器:直接把Java文件转换为本地机器码的过程

解释器与编译器

image

当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。

在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率

分层编译的策略TieredCompilation

为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机还会逐渐启动分层编译的策略。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,包括:

  1. 第0层,程序解释执行,解释器不开启性能监控功能,可触发第1层编译。
  2. 第1层,也称为C1编译,将字节码编译成本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑。
  3. 第2层,也称为C2编译,也是将字节码编译为本地代码,但是会启动一下编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

实施分层编译后,Client Compiler和Server Compiler 将会同时工作,许多代码都可能会多次编译,C1获取更高的编译速度,用C2获取更好的编译质量,在解释时候的时候也无须再承担收集性能监控信息的任务。

1
2
3
4
Oracle JDK从JDK 6u25以后的版本支持了多层编译(-XX:+TieredCompilation)
可以用jinfo -flag或-XX:+PrintFlagsFinal来确认是否打开

JDK8是默认打开的

image

图表描绘了纯解析、客户端、服务器端和混合编译的性能区别。X轴代码执行时间,Y轴代表性能

和单纯的代码解析相比,使用客户端编译器可以提高大约5-10倍的执行性能,实际上提高了应用的性能。当然,收益的变化还是依赖于编译器性能如何,哪些优化生效了或者被实现了,还有就是对于目标执行平台来说应用程序设计的有多好。后者是Java开发人员从来不需要担心的。

和客户端编译器相比,服务器端编译器通常能够提升可度量的30%-50%的代码效率。在大部分情况下,这性能的提高将平衡掉多余的资源开销。

分层编译结合了两种编译器的优点。客户端编译产生了快速的启动时间和及时的优化,服务器端编译在执行周期的后期,可以提供更多的高级优化

JIT开关

image

3种执行方式,分别是解释执行、混合模式和编译执行,默认情况下处于混合模式中

1
通过-Xint  -Xcomp改变执行方式

通过代码也可以

1
2
3
java.lang.Compiler.disable();

java.lang.Compiler.enable();

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Pi {
public static double calcPi() {
double re = 0;
for (int i = 1; i < 100000; i++) {
re += ((i & 1) == 0 ? -1 : 1) * 1.0 / (2 * i - 1);
}
return re * 4;
}

public static void main(String[] args) {
long b = System.currentTimeMillis();
for (int i = 0; i < 100000; i++)
calcPi();
long e = System.currentTimeMillis();
System.err.println("spend:" + (e - b) + "ms");
}
}
mixed:spend:418ms
int:spend:2547ms
comp:spend:416ms

jstat -compiler 显示VM实时编译的数量等信息。
显示列名
具体描述
Compiled
编译任务执行数量
Failed
编译任务执行失败数量
Invalid
编译任务执行失效数量
Time
编译任务消耗时间
FailedType
最后一个编译失败任务的类型
FailedMethod
最后一个编译失败任务所在的类及方法

1
2
3
jstat -compiler 55417
Compiled Failed Invalid Time FailedType FailedMethod
6296 6 0 50.37 1 org/eclipse/jdt/internal/core/CompilationUnitStructureRequestor createTypeInfo

JIT原理

image

寻找热点代码

在运行过程中,会被即时编译器编译的“热点代码”有两类,即:

  1. 被多次调用的方法
  2. 被多次执行的循环体(OSR On StackReplacement)

判断是否是热点代码的行为成为热点探测:hot spotdetection,主要的热点探测方式主要有两种:

  1. 基于采样的热点探测,JVM会周期性检查各个线程的栈顶,如果某个方法经常出现在栈顶,那就认定为热点方法。简单高效,精度不够。

  2. 基于计数器的热点探测,统计方法执行次数。(HOTSPOT使用这种方式)

计数器

HOTSPOT有两个计数器:

1、方法调用计数器

2、回边计数器

方法调用计数器

image

1
2
3
4
5
方法调用计数器
client默认1500次,
server默认10000次,

-XX:CompileThreshold

方法调用计数器并不是统计方法调用绝对次数,而是一个相对执行频率,
超过一定时间,如果方法调用次数不足以让它提交给编译器,则计数器就会被减少一半,这种现象称为热度衰减(Counter Decay),
进行热度衰减的动作是在垃圾回收时顺便进行的,而这段时间就被称为半衰周期(Counter Half Life Time)

1
2
可用-XX:-UseCounterDecay来关闭热度衰减,
用-XX:CounterHalfLifeTime来设置半衰时间。

要不要关闭这个衰减?

HotSpot VM的触发JIT的计数器的半衰(counter decaying)是一种很好的机制,保证只有真正热的代码才会被编译,而那种偶尔才被调用一次的方法则不会因为运行时间长而积累起虚假的热度。

不建议关闭这个选项,除非它在具体案例中造成了问题。

Counter decaying是伴随GC运行而执行的。过多的手动调用System.gc()倒是有可能会干扰了这个衰减,导致方法达不到触发JIT编译的热度。

回边计数器

image

回边计数器阈值计算公式

(1)Client模式下
方法调用计数器阈值(CompileThreshold)* OSR比率(OnStackReplacePercentage)/100.
其中OnStackReplacePercentage默认值为933,如果都取默认值,那Client模式虚拟机回边数的阈值为13995。

(2)Server模式下
方法调用计数器阈值(CompileThreshold)*(OSR比率(OnStackReplacePercentage)减去解释器监控比率(InterpreterProfilePercentage)的差值)/100。

其中OnStackReplacePercentage默认值为140,InterpreterProfilePercentage默认值为33,
如果都取默认值,那Server模式虚拟机回边计数器的阈值为10700

1
2
-XX:BackEdgeThreshold
-XX:OnStackReplacePercentage

与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数

学习JIT意义

大方法 与 小方法?

java中一般建议一个方法不要写的过长,不方便维护和阅读是其中的一个原因,但是其真正性能的原因大家知道吗?

我们知道,JVM一开始是以解释方式执行字节码的。当这段代码被执行的次数足够多以后,它会被动态优化并编译成机器码执行,执行速度会大大加快,这就是所谓的JIT编译。
hotsopt源码中有一句

1
if (DontCompileHugeMethods && m->code_size() > HugeMethodLimit) return false;

当DontCompileHugeMethods=true且代码长度大于HugeMethodLimit时,方法不会被编译

DontCompileHugeMethods与HugeMethodLimit的值在globals.hpp中定义:

1
2
3
4
product(bool, DontCompileHugeMethods, true,
"don't compile methods > HugeMethodLimit")
develop(intx, HugeMethodLimit, 8000,
"don't compile methods larger than this if +DontCompileHugeMethods")

上面两个参数说明了Hotspot对字节码超过8000字节的大方法有JIT编译限制,这就是大方法不会被JIT编译的原因。由于使用的是product mode的JRE,

我们只能尝试关闭DontCompileHugeMethods,即增加VM参数”-XX:-DontCompileHugeMethods”来强迫JVM编译大方法。

但是不建议这么做,因为一旦CodeCache满了,HotSpot会停止所有后续的编译任务,虽然已编译的代码不受影响,但是后面的所有方法都会强制停留在纯解释模式。

查看jit工作的参数

1
2
3
4
5
6
7
8
9
10
-XX:-CITime 打印发费在JIT编译上的时间

$ java -server -XX:+CITime Benchmark
[...]
Accumulated compiler times (for compiled methods only)
------------------------------------------------
Total compilation time : 0.178 s
Standard compilation : 0.129 s, Average : 0.004
On stack replacement : 0.049 s, Average : 0.024
[...]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-XX:+PrintCompilation 我们可以简单的输出一些关于从字节码转化成本地代码的编译过程

$ java -server -XX:+PrintCompilation Benchmark
1 java.lang.String::hashCode (64 bytes)
2 java.lang.AbstractStringBuilder::stringSizeOfInt (21 bytes)
3 java.lang.Integer::getChars (131 bytes)
4 java.lang.Object::<init> (1 bytes)
--- n java.lang.System::arraycopy (static)
5 java.util.HashMap::indexFor (6 bytes)
6 java.lang.Math::min (11 bytes)
7 java.lang.String::getChars (66 bytes)
8 java.lang.AbstractStringBuilder::append (60 bytes)
9 java.lang.String::<init> (72 bytes)
10 java.util.Arrays::copyOfRange (63 bytes)
11 java.lang.StringBuilder::append (8 bytes)
12 java.lang.AbstractStringBuilder::<init> (12 bytes)
13 java.lang.StringBuilder::toString (17 bytes)
14 java.lang.StringBuilder::<init> (18 bytes)
15 java.lang.StringBuilder::append (8 bytes)
[...]
29 java.util.regex.Matcher::reset (83 bytes)

每当一个方法被编译,就输出一行-XX:+PrintCompilation。每行都包含顺序号(唯一的编译任务ID)和已编译方法的名称和大小。

因此,顺序号1,代表编译String类中的hashCode方法到原生代码的信息。根据方法的类型和编译任务打印额外的信息。例如,本地的包装方法前方会有”n”参数,像上面的System::arraycopy一样。注意这样的方法不会包含顺序号和方法占用的大小,因为它不需要编译为本地代码。

同样可以看到被重复编译的方法,例如StringBuilder::append顺序号为11和15。输出在顺序号29时停止 ,这表明在这个Java应用运行时总共需要编译29个方法。

GC及JVM参数

发表于 2017-02-22
字数统计: 13.9k 字数 | 阅读时长 ≈ 51 分钟

前言

这段时间懈怠了,罪过!

最近看到有同事也开始用上了微信公众号写博客了,挺好的~给他们点赞,这博客我也不推广,默默的静静的,主要是担心自己坚持不了。以前写过时间事件日志现在也不写了;写过博客也不写了;月记也不写了。

坚持平凡事就是伟大,本来计划一周一篇的,这次没有严格执行。懈怠了

这个GC跟JVM内容太多了,理论性东西多些,少年时还能记个八九成,好久没弄,都忘记了。这次权当整理温习,再看看《深入理解JVM虚拟机》,找些过去写的博客挖点东西过来!

GC

Java GC(Garbage Collection,垃圾收集,垃圾回收)机制,是Java与C++/C的主要区别之一,作为Java开发者,一般不需要专门编写内存回收和垃圾清理代码,对内存泄露和溢出的问题,也不需要像C程序员那样战战兢兢。这是因为在Java虚拟机中,存在自动内存管理和垃圾清扫机制。概括地说,该机制对虚拟机中的内存进行标记,并确定哪些内存需要回收,根据一定的回收策略,自动的回收内存,永不停息(Nerver Stop)的保证虚拟机中的内存空间,防止出现内存泄露和溢出问题。

主要从这几个问题入手,就差不多了

  1. Java内存区域
  2. 哪些内存需要回收?
  3. 什么时候回收
  4. 如何回收
  5. 监控和优化GC

Java内存区域

image

  1. 程序计数器(Program Counter Register)

程序计数器是一个比较小的内存区域,用于指示当前线程所执行的字节码执行到了第几行,可以理解为是当前线程的行号指示器。字节码解释器在工作时,会通过改变这个计数器的值来取下一条语句指令。每个程序计数器只用来记录一个线程的行号,所以它是线程私有(一个线程就有一个程序计数器)的。
如果程序执行的是一个Java方法,则计数器记录的是正在执行的虚拟机字节码指令地址;如果正在执行的是一个本地(native,由C语言编写完成)方法,则计数器的值为Undefined,由于程序计数器只是记录当前指令地址,所以不存在内存溢出的情况,因此,程序计数器也是所有JVM内存区 域中唯一一个没有定义OutOfMemoryError的区域。

  1. 虚拟机栈(JVM Stack)

一个线程的每个方法在执行的同时,都会创建一个栈帧(Statck Frame),栈帧中存储的有局部变量表、操作站、动态链接、方法出口等,当方法被调用时,栈帧在JVM栈中入栈,当方法执行完成时,栈帧出栈。局部变量表中存储着方法的相关局部变量,包括各种基本数据类型,对象的引用,返回地址等。在局部变量表中,只有long和double类型会占用2个局部变量空间(Slot,对于32位机器,一个Slot就是32个bit),其它都是1个Slot。需要注意的是,局部变量表是在编译时就已经确定 好的,方法运行所需要分配的空间在栈帧中是完全确定的,在方法的生命周期内都不会改变。虚拟机栈中定义了两种异常,如果线程调用的栈深度大于虚拟机允许的最大深度,则抛出StatckOverFlowError(栈溢出);不过多 数Java虚拟机都允许动态扩展虚拟机栈的大小(有少部分是固定长度的),所以线程可以一直申请栈,知道内存不足,此时,会抛出 OutOfMemoryError(内存溢出)。每个线程对应着一个虚拟机栈,因此虚拟机栈也是线程私有的。

  1. 本地方法栈(Native Method Statck):

本地方法栈在作用,运行机制,异常类型等方面都与虚拟机栈相同,唯一的区别是:虚拟机栈是执行Java方法的,而本地方法栈是用来执行native方法的,在很多虚拟机中(如Sun的JDK默认的HotSpot虚拟机),会将本地方法栈与虚拟机栈放在一起使用。本地方法栈也是线程私有的。

  1. 堆区(Heap)

堆区是理解Java GC机制最重要的区域,没有之一。在JVM所管理的内存中,堆区是最大的一块,堆区也是Java GC机制所管理的主要内存区域,堆区由所有线程共享,在虚拟机启动时创建。堆区的存在是为了存储对象实例,原则上讲,所有的对象都在堆区上分配内存(不过现代技术里,也不是这么绝对的,也有栈上直接分配的)。一般的,根据Java虚拟机规范规定,堆内存需要在逻辑上是连续的(在物理上不需要),在实现时,可以是固定大小的,也可以是可扩展的,目前主流的虚拟机都是可扩展的。如果在执行垃圾回收之后,仍没有足够的内存分配,也不能再扩展,将会抛出OutOfMemoryError:Java heap space异常。

1
2
3
4
5
6
7
-Xms 参数设置最小值

-Xmx 参数设置最大值
例:VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError

若-Xms=-Xmx,则可避免堆自动扩展。
-XX:+HeapDumpOnOutOfMemoryError 可以让虚拟机在出现内存溢出是dump出当前的内存堆转储快照。

  1. 方法区(Method Area)

在Java虚拟机规范中,将方法区作为堆的一个逻辑部分来对待,但事实上,方法区并不是堆(Non-Heap);另外,不少人的博客中,将Java GC的分代收集机制分为3个代:青年代,老年代,永久代,这些作者将方法区定义为“永久代”,这是因为,对于之前的HotSpot Java虚拟机的实现方式中,将分代收集的思想扩展到了方法区,并将方法区设计成了永久代。不过,除HotSpot之外的多数虚拟机,并不将方法区当做永久代,HotSpot本身,也计划取消永久代。
方法区是各个线程共享的区域,用于存储已经被虚拟机加载的类信息(即加载类时需要加载的信息,包括版本、field、方法、接口等信息)、final常量、静态变量、编译器即时编译的代码等。方法区在物理上也不需要是连续的,可以选择固定大小或可扩展大小,并且方法区比堆还多了一个限制:可以选择是否执行垃圾收集。一般的,方法区上 执行的垃圾收集是很少的,这也是方法区被称为永久代的原因之一(HotSpot),但这也不代表着在方法区上完全没有垃圾收集,其上的垃圾收集主要是针对常量池的内存回收和对已加载类的卸载。在方法区上进行垃圾收集,条件苛刻而且相当困难,效果也不令人满意,所以一般不做太多考虑,可以留作以后进一步深入研究时使用。在方法区上定义了OutOfMemoryError:PermGen space异常,
在内存不足时抛出。
运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存储编译期就生成的字面常量、符号引用、翻译出来的直接引用(符号引用就是编码是用字符串表示某个变量、接口的位置,直接引用就是根据符号引用翻译出来的地址,将在类链接阶段完成翻译);运行时常量池除了存储编译期常量外,也可以存储在运行时间产生的常量(比如String类的intern()方法,作用是String维护了一个常量池,如果调用的字符“abc”已经在常量池中,则返回池中的字符串地址,否则,新建一个常量加入池中,并返回地址)。

1
2
3
-XX:MaxPermSize 设置上限
-XX:PermSize 设置最小值
例:VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M

  1. 直接内存(Direct Memory)

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现,所以我们放到这里一起讲解。

Direct Memory满了之后,系统不会自动回收这段内存; 而是要等Tenured Generation满触发GC时,Direct Memory才会被跟着回收。

在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括RAM及SWAP区或者分页文件)的大小及处理器寻址空间的限制。服务器管理员配置虚拟机参数时,一般会根据实际内存设置-Xmx等参数信息,但经常会忽略掉直接内存,使得各个内存区域的总和大于物理内存限制(包括物理上的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。

1
2
3
-XX:MaxDirectMemorySize 设置最大值,默认与java堆最大值一样。

例 :-XX:MaxDirectMemorySize=10M -Xmx20M

哪些内存被回收

根据运行时数据区域的各个部分,程序计数器、虚拟机栈、本地方法栈三个区域随着线程而生,随线程灭而灭。栈中的栈帧随着方法的进入和退出而进栈出栈。每个栈帧分配多少内存在类结构确定下来的时候就基本已经确定。所以这个三个区域内存回收时方法或者线程结束而回收的,不需要太多关注;而java堆和方法区则不一样,一个接口不同实现类,一个方法中不同的分支,在具体运行的时候才能确定创建那些对象,所以这部分内存是动态的,也是需要垃圾回收机制来回收处理的。

  1. 堆内存

判断堆内的对象是否可以回收,要判断这个对象实例是否确实没用,判断算法有两种:引用计数法和根搜索算法。

  • 引用计数法:就是给每个对象加一个计数器,如果有一个地方引用就加1,当引用失效就减1;当计数器为0,则认为对象是无用的。这种算法最大的问题在于不能解决相互引用的对象,如:A.b=B;B.a=A,在没有其他引用的情况下,应该回收;但按照引用计数法来计算,他们的引用都不为0,显然不能回收。

  • 根搜索算法:这个算法的思路是通过一系列名为“GC Roots”的对象作为起点,
    从这个节点向下搜索,搜索所经过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(图论的不可达)时,则证明该对象不可用。

java等一大部分商用语言是用根搜索算法来管理内存的,java中可以做为GC Roots的对象有如下几种:

虚拟机栈(栈帧中的本地变量表)中的引用的对象;

方法区中的类静态属性引用的对象;

方法区中常量引用的对象;

本地方法栈JNI(Native)的引用对象;

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。在JDK1.2以前,Java中的引用的定义很传统如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但是太过狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态,对于如何描述一些“食之无味,弃之可惜”的对象就显得无能为力。我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。

在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

  • 强引用

只要强引用还存在,垃圾收集器永远不会收掉被引用的对象

  • 软引用

在系统将要发生内存异常之前,将会把这些对象列进回收范围之中进行第二次回收。

  • 弱引用

被弱引用关联的对象只能生存道下一次垃圾收集发生之前。

  • 虚引用

一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象的实例。

finalize()方法

在Object类中

1
protected void finalize() throws Throwable { }

注意下这个访问控制符是protected

finalize()在什么时候被调用?

有三种情况

  1. 所有对象被Garbage Collection时自动调用,比如运行System.gc()的时候.
  2. 程序退出时为每个对象调用一次finalize方法。
  3. 显式的调用finalize方法

当一个对象不可到达时,并不是马上就被回收的。

image

当对象没有覆盖finalize()方法,或者finalized()已经被JVM调用过,那就是没有必要执行finalzied()
;Finalizer线程执行它,但并不保证等待它执行结束,这主要是防止finalize()出现问题,导致Finalizer线程无限等待,整个内存回收系统崩溃

具体的finalize流程:

对象可由两种状态,涉及到两类状态空间,一是终结状态空间 F = {unfinalized, finalizable, finalized};二是可达状态空间 R = {reachable, finalizer-reachable, unreachable}。各状态含义如下:
unfinalized: 新建对象会先进入此状态,GC并未准备执行其finalize方法,因为该对象是可达的
finalizable: 表示GC可对该对象执行finalize方法,GC已检测到该对象不可达。正如前面所述,GC通过F-Queue队列和一专用线程完成finalize的执行
finalized: 表示GC已经对该对象执行过finalize方法
reachable: 表示GC Roots引用可达
finalizer-reachable(f-reachable):表示不是reachable,但可通过某个finalizable对象可达
unreachable:对象不可通过上面两种途径可达
image

  1. 新建对象首先处于[reachable, unfinalized]状态(A)
  2. 随着程序的运行,一些引用关系会消失,导致状态变迁,从reachable状态变迁到f-reachable(B, C, D)或unreachable(E, F)状态
  3. 若JVM检测到处于unfinalized状态的对象变成f-reachable或unreachable,JVM会将其标记为finalizable状态(G,H)。若对象原处于[unreachable, unfinalized]状态,则同时将其标记为f-reachable(H)。
  4. 在某个时刻,JVM取出某个finalizable对象,将其标记为finalized并在某个线程中执行其finalize方法。由于是在活动线程中引用了该对象,该对象将变迁到(reachable, finalized)状态(K或J)。该动作将影响某些其他对象从f-reachable状态重新回到reachable状态(L, M, N), 这就是对象重生
  5. 处于finalizable状态的对象不能同时是unreahable的,由第4点可知,将对象finalizable对象标记为finalized时会由某个线程执行该对象的finalize方法,致使其变成reachable。这也是图中只有八个状态点的原因
  6. 程序员手动调用finalize方法并不会影响到上述内部标记的变化,因此JVM只会至多调用finalize一次,即使该对象“复活”也是如此。程序员手动调用多少次不影响JVM的行为
  7. 若JVM检测到finalized状态的对象变成unreachable,回收其内存(I)
  8. 若对象并未覆盖finalize方法,JVM会进行优化,直接回收对象(O)

注:System.runFinalizersOnExit()等方法可以使对象即使处于reachable状态,JVM仍对其执行finalize方法

对finalize()的一句话概括:
JVM能够保证一个对象在回收以前一定会调用一次它的finalize()方法。这句话中两个陷阱:回收以前一定和一次

但有很多地方是讲,JVM不承诺这一定调用finalize(),这就是上面的陷阱造成的

你永远不知道它什么时候被调用甚至会不会调用(因为有些对象是永远不会被回收的,或者被回收以前程序就结束了),但如果他是有必要执行finalize()的,那在GC前一定调用一次且仅一次,如果在第一次GC时没有被回收,那以后再GC时,就不再调用finalize()

  1. 方法区

很多人认为方法区(或者HotSpot虚拟机中的永久代)是没有垃圾收集的,Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区进行垃圾收集的“性价比”一般比较低:在堆中,尤其是在新生代中,常规应用进行一次垃圾收集*++一般可以回收70%~95%的空间++,而永久代的垃圾收集效率远低于此。

方法区回收主要有两部分:废弃的常量和无用的类。废弃的常量判断方法和堆中的对象类似,只要判断没有地方引用就可以回收。相比之下,判断一个类是否无用,条件就比较苛刻,需要同时满足下面3个条件才能算是“无用的类”:

  • 该类的所有实例都已经被回收,也就是java堆中不存在该类的任何实例;
  • 加载该类的ClassLoader已经被回收;
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而不是和对象一样,不使用了就必然会回收。是否对类进行回收,

1
2
3
4
5
6
HotSpot虚拟机提供了
-Xnoclassgc参数进行控制,
还可以使用
-verbose:class
-XX:+TraceClassLoading
-XX:+TraceClassUnLoading查看类的加载和卸载信息。

在大量使用反射、动态代理、CGLib等bytecode框架的场景,以及动态生成JSP和OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出

如何回收

选择合适的GC collector是JVM调优最重要的一项,前提是先了解回收算法

“标记-清除”(Mark-Sweep)

image

算法分为“标记”和“清除”两个阶段:
首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象

主要缺点有两个

  1. 一个是效率问题,标记和清除过程的效率都不高
  2. 一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

“复制”(Copying)

image

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

只是这种算法的代价是将内存缩小为原来的一半,未免太高了一点

现在的商业虚拟机都采用这种收集算法来回收新生代,IBM的专门研究表明,新生代中的对象98%是朝生夕死的,所以并不需要按照1∶1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存是会被“浪费”的。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

1
2
3
-XX:SurvivorRatio=4
设置年轻代中Eden区与Survivor区的大小比值。
设置为4,则Eden区与两个Survivor区的比值为4:1:1,一个Survivor区占整个年轻代的1/6

为什么新生代有两个survivor?
StackOverflow上面给出的解释是:

The reason for the HotSpot JVM’s two survivor spaces is to reduce the need to deal with fragmentation. New objects are allocated in eden space. All well and good. When that’s full, you need a GC, so kill stale objects and move live ones to a survivor space, where they can mature for a while before being promoted to the old generation. Still good so far. The next time we run out of eden space, though, we have a conundrum. The next GC comes along and clears out some space in both eden and our survivor space, but the spaces aren’t contiguous. So is it better to

  1. Try to fit the survivors from eden into the holes in the survivor space that were cleared by the GC?
  2. Shift all the objects in the survivor space down to eliminate the fragmentation, and then move the survivors into it?
  3. Just say “screw it, we’re moving everything around anyway,” and copy all of the survivors from both spaces into a completely separate space–the second survivor space–thus leaving you with a clean eden and survivor space where you can repeat the sequence on the next GC?

Sun’s answer to the question is obvious.

“标记-整理”(Mark-Compact)

image

此算法结合了“标记-清除”和“复制”两个算法的优点。也是分两阶段,

  1. 第一阶段从根节点开始标记所有被引用对象,
  2. 第二阶段遍历整个堆,把清除未标记对象并且把存活对象“压缩”到堆的其中一块,按顺序排放。此算法避免了“标记-清除”的碎片问题,同时也避免了“复制”算法的空间问题。

“分代收集”(Generational Collection)

当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,
这种算法并没有什么新的思想,只是根据对象的存活周期的不同将内存划分为几块。
一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

image

  • 新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具
    备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。

  • 老年代 GC(Major GC):指发生在老年代的 GC,出现了 Major GC,经常
    会伴随至少一次的 Minor GC(但非绝对的,在 ParallelScavenge 收集器的收集策略里
    就有直接进行 Major GC 的策略选择过程) 。MajorGC 的速度一般会比 Minor GC 慢 10
    倍以上。
    虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁)时,就会被晋升到老年代中。

    1
    对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

垃圾收集器

image

按系统线程分

image

注意并发(Concurrent)和并行(Parallel)的区别:

  1. 并发是指用户线程与GC线程同时执行(不一定是并行,可能交替,但总体上是在同时执行的),不需要停顿用户线程(其实在CMS中用户线程还是需要停顿的,只是非常短,GC线程在另一个CPU上执行);
  2. 并行收集是指多个GC线程并行工作,但此时用户线程是暂停的;

这个跟传统的并发并行概念不同
并行是物理的,并发是逻辑的。
并行是和串行对立。

Serial收集器

image
Serial是最基本、历史最悠久的垃圾收集器,使用复制算法,曾经是JDK1.3.1之前新生代唯一的垃圾收集器。目前也是ClientVM下 ServerVM 4核4GB以下机器的默认垃圾回收器。
串行收集器并不是只能使用一个CPU进行收集,而是当JVM需要进行垃圾回收的时候,需要中断所有的用户线程,知道它回收结束为止,因此又号称“Stop The World” 的垃圾回收器。注意,JVM中文名称为java虚拟机,因此它就像一台虚拟的电脑一样在工作,而其中的每一个线程就被认为是JVM的一个处理器,因此大家看到图中的CPU0、CPU1实际为用户的线程,而不是真正机器的CPU,大家不要误解哦。

串行回收方式适合低端机器,是Client模式下的默认收集器,对CPU和内存的消耗不高,适合用户交互比较少,后台任务较多的系统。

Serial收集器默认新旧生代的回收器搭配为Serial+ SerialOld

新生代、老年代使用串行回收;新生代复制算法、老年代标记-压缩

1
2
3
在J2SE5.0上,在非server模式下,JVM自动选择串行收集器。
也可以显示进行选择,在Java启动参数中增加:
-XX:+UseSerialGC

Serial Old收集器

SerialOld是旧生代Client模式下的默认收集器,单线程执行,使用“标记-整理”算法
在Server模式下,主要有两个用途:

  1. 在JDK1.5之前版本中与新生代的Parallel Scavenge收集器搭配使用。
  2. 作为年老代中使用CMS收集器的后备垃圾收集方案。

ParNew收集器

image
ParNew收集器其实就是多线程版本的Serial收集器,

Stop The World

他是多CPU模式下的首选回收器(该回收器在单CPU的环境下回收效率远远低于Serial收集器,所以一定要注意场景哦)

Server模式下的默认收集器。

新生代并行,老年代串行;新生代复制算法、老年代标记-压缩

1
2
3
4
-XX:+UseParNewGC  ParNew收集器

ParNew收集器默认开启和CPU数目相同的线程数
-XX:ParallelGCThreads 限制线程数量

Parallel Scavenge收集器

Parallel Scavenge收集器也是一个新生代垃圾收集器,同样使用复制算法,也是一个多线程的垃圾收集器,也称吞吐量优先的收集器

所提到的吞吐量=程序运行时间/(JVM执行回收的时间+程序运行时间),假设程序运行了100分钟,JVM的垃圾回收占用1分钟,那么吞吐量就是99%。在当今网络告诉发达的今天,良好的响应速度是提升用户体验的一个重要指标,多核并行云计算的发展要求程序尽可能的使用CPU和内存资源,尽快的计算出最终结果,因此在交互不多的云端,比较适合使用该回收器。

可以通过参数来打开自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量;也可以通过参数控制GC的时间不大于多少毫秒或者比例

新生代复制算法、老年代标记-压缩

1
2
3
4
5
6
7
8
9
10
-XX:+UseParallelGC  使用Parallel收集器+ 老年代串行

Parallel Scavenge收集器提供了两个参数用于精准控制吞吐量:
a.-XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间,是一个大于0的毫秒数。
b.-XX:GCTimeRation:直接设置吞吐量大小,是一个大于0小于100的整数,
也就是程序运行时间占总时间的比率,默认值是99,即垃圾收集运行最大1%(1/(1+99))的垃圾收集时间

-XX:+UseAdaptiveSizePolicy,这是个开关参数,
打开之后就不需要手动指定新生代大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、
新生代晋升年老代对象年龄(-XX:PretenureSizeThreshold)等细节参数

Parallel Old

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供

在JDK1.6之前,新生代使用ParallelScavenge收集器只能搭配年老代的Serial Old收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量,Parallel Old正是为了在年老代同样提供吞吐量优先的垃圾收集器,如果系统对吞吐量要求比较高,可以优先考虑新生代Parallel Scavenge和年老代Parallel Old收集器的搭配策略

1
参数控制: -XX:+UseParallelOldGC 使用Parallel收集器+ 老年代并行

新生代Parallel Scavenge和年老代Parallel Old收集器搭配运行过程图:
image

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。

由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行。老年代收集器(新生代使用ParNew)

优点:并发收集、低停顿

缺点:产生大量空间碎片、并发阶段会降低吞吐量

从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤,包括:

  1. 初始标记(CMS initial mark)

  2. 并发标记(CMS concurrent mark)

  3. 重新标记(CMS remark)

  4. 并发清除(CMS concurrent sweep)

a.初始标记:只是标记一下GC Roots能直接关联的对象,速度很快,仍然需要暂停所有的工作线程。

b.并发标记:进行GC Roots跟踪的过程,和用户线程一起工作,不需要暂停工作线程。

c.重新标记:为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停所有的工作线程。

d.并发清除:清除GC Roots不可达对象,和用户线程一起工作,不需要暂停工作线程。
由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS收集器的内存回收和用户线程是一起并发地执行。

CMS收集器工作过程:

image

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。

CMS收集器有以下三个不足:

  • CMS收集器对CPU资源非常敏感,其默认启动的收集线程数=(CPU数量+3)/4,在用户程序本来CPU负荷已经比较高的情况下,如果还要分出CPU资源用来运行垃圾收集器线程,会使得CPU负载加重。

  • CMS无法处理浮动垃圾(Floating Garbage),可能会导致Concurrent ModeFailure失败而导致另一次Full GC。由于CMS收集器和用户线程并发运行,因此在收集过程中不断有新的垃圾产生,这些垃圾出现在标记过程之后,CMS无法在本次收集中处理掉它们,只好等待下一次GC时再将其清理掉,这些垃圾就称为浮动垃圾。
    CMS垃圾收集器不能像其他垃圾收集器那样等待年老代机会完全被填满之后再进行收集,需要预留一部分空间供并发收集时的使用,可以通过参数-XX:CMSInitiatingOccupancyFraction来设置年老代空间达到多少的百分比时触发CMS进行垃圾收集,默认是68%。
    如果在CMS运行期间,预留的内存无法满足程序需要,就会出现一次ConcurrentMode Failure失败,此时虚拟机将启动预备方案,使用Serial Old收集器重新进行年老代垃圾回收。

  • CMS收集器是基于标记-清除算法,因此不可避免会产生大量不连续的内存碎片,如果无法找到一块足够大的连续内存存放对象时,将会触发因此Full GC。CMS提供一个开关参数-XX:+UseCMSCompactAtFullCollection,用于指定在Full GC之后进行内存整理,内存整理会使得垃圾收集停顿时间变长,CMS提供了另外一个参数-XX:CMSFullGCsBeforeCompaction,用于设置在执行多少次不压缩的Full GC之后,跟着再来一次内存整理

    1
    2
    3
    4
    -XX:+UseConcMarkSweepGC  使用CMS收集器
    -XX:+ UseCMSCompactAtFullCollection Full GC后,进行一次碎片整理;整理过程是独占的,会引起停顿时间变长
    -XX:+CMSFullGCsBeforeCompaction 设置进行几次Full GC后,进行一次碎片整理
    -XX:ParallelCMSThreads 设定CMS的线程数量(一般情况约等于可用CPU数量)

G1收集器

G1可谓博采众家之长,力求到达一种完美。他吸取了增量收集优点,把整个堆划分为一个一个等大小的区域(region)。内存的回收和划分都以region为单位;同时,他也吸取了CMS的特点,把这个垃圾回收过程分为几个阶段,分散一个垃圾回收过程;而且,G1也认同分代垃圾回收的思想,认为不同对象的生命周期不同,可以采取不同收集方式,因此,它也支持分代的垃圾回收。为了达到对回收时间的可预计性,G1在扫描了region以后,对其中的活跃对象的大小进行排序,首先会收集那些活跃对象小的region,以便快速回收空间(要复制的活跃对象少了),因为活跃对象小,里面可以认为多数都是垃圾,所以这种方式被称为Garbage First(G1)的垃圾回收算法,即:垃圾优先的回收。

image

与CMS收集器相比G1收集器有以下特点:

  1. 空间整合,G1收集器采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次GC。

  2. 可预测停顿,这是G1的另一大优势,降低停顿时间是G1和CMS的共同关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为N毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。

收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续)Region的集合。

G1的新生代收集跟ParNew类似,当新生代占用达到一定比例的时候,开始出发收集。

和CMS类似,G1收集器收集老年代对象会有短暂停顿。

  1. 标记阶段,首先初始标记(Initial-Mark),这个阶段是停顿的(Stop the World Event),并且会触发一次普通Mintor GC。对应GC log:GC pause (young) (inital-mark)

  2. Root Region Scanning,程序运行过程中会回收survivor区(存活到老年代),这一过程必须在young GC之前完成。

  3. Concurrent Marking,在整个堆中进行并发标记(和应用程序并发执行),此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那个这个区域会被立即回收(图中打X)。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
    image

  4. Remark, 再标记,会有短暂停顿(STW)。再标记阶段是用来收集 并发标记阶段 产生新的垃圾(并发阶段和应用程序一同运行);G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning (SATB)。

  5. Copy/Clean up,多线程清除失活对象,会有STW。G1将回收区域的存活对象拷贝到新区域,清除Remember Sets,并发清空回收区域并把它返回到空闲区域链表中。
    image

  6. 复制/清除过程后。回收区域的活性对象已经被集中回收到深蓝色和深绿色区域。
    image

1
2
3
4
5
-XX:+UnlockExperimentalVMOptions -XX:+UseG1GC        #开启
-XX:MaxGCPauseMillis =50 #暂停时间目标
-XX:GCPauseIntervalMillis =200 #暂停间隔目标
-XX:+G1YoungGenSize=512m #年轻代大小
-XX:SurvivorRatio=6 #幸存区比例

什么时候回收

Minor GC触发

  1. Eden区域满了,或者新创建的对象大小 > Eden所剩空间
  2. CMS设置了CMSScavengeBeforeRemark参数,这样在CMS的Remark之前会先做一次Minor GC来清理新生代,加速之后的Remark的速度。这样整体的stop-the world时间反而短
  3. Full GC的时候会先触发Minor GC

啥时候会触发CMS GC?

CMS不等于Full GC,很多人会认为CMS肯定会引发Minor GC。CMS是针对老年代的GC策略,原则上它不会去清理新生代,只有设置CMSScavengeBeforeRemark优化时,或者是concurrent mode failure的时候才会去做Minor GC

1、旧生代或者持久代已经使用的空间达到设定的百分比时(CMSInitiatingOccupancyFraction这个设置old区,perm区也可以设置);

2、JVM自动触发(JVM的动态策略,也就是悲观策略)(基于之前GC的频率以及旧生代的增长趋势来评估决定什么时候开始执行),如果不希望JVM自行决定,可以通过-XX:UseCMSInitiatingOccupancyOnly=true来制定;

3、设置了 -XX:CMSClassUnloadingEnabled 这个则考虑Perm区;

啥时候会触发Full GC?

一、旧生代空间不足:java.lang.outOfMemoryError:java heap space;

二、Perm空间满:java.lang.outOfMemoryError:PermGen space;

三、CMS GC时出现promotion failed 和concurrent mode failure(Concurrent mode failure发生的原因一般是CMS正在进行,但是由于old区内存不足,需要尽快回收old区里面的死的java对象,这个时候foreground gc需要被触发,停止所有的java线程,同时终止CMS,直接进行MSC。);

四、统计得到的minor GC晋升到旧生代的平均大小大于旧生代的剩余空间;

五、主动触发Full GC(执行jmap -histo:live [pid])来避免碎片问题;

六、调用System.gc时,系统建议执行Full GC,但是不必然执行,-XX:+DisableExplicitGC 禁用System.gc()调用

GC策略选择总结

jvm有client和server两种模式,这两种模式的gc默认方式是不同的:

client模式下,新生代选择的是串行gc,旧生代选择的是串行gc

server模式下,新生代选择的是并行回收gc,旧生代选择的是并行gc

一般来说我们系统应用选择有两种方式:吞吐量优先和暂停时间优先,对于吞吐量优先的采用server默认的并行gc方式,对于暂停时间优先的选用并发gc(CMS)方式。

监控与调优

GC日志

1
2
3
4
5
6
7
8
-XX:+PrintGC 输出GC日志
-XX:+PrintGCDetails 输出GC的详细日志
-XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
-Xloggc:../logs/gc.log 日志文件的输出路径
-verbose.gc开关可显示GC的操作内容。
打开它,可以显示最忙和最空闲收集行为发生的时间、收集前后的内存大小、收集需要的时间等

-XX:+PrintGCTimeStamps和-XX:+PrintGCDateStamps

使用-XX:+PrintGCTimeStamps可以将时间和日期也加到GC日志中。表示自JVM启动至今的时间戳会被添加到每一行中。例子如下:

1
2
3
1 0.185: [GC 66048K->53077K(251392K), 0.0977580 secs]
2 0.323: [GC 119125K->114661K(317440K), 0.1448850 secs]
3 0.603: [GC 246757K->243133K(375296K), 0.2860800 secs]

如果指定了-XX:+PrintGCDateStamps,每一行就添加上了绝对的日期和时间。

1
2
3
1 2014-01-03T12:08:38.102-0100: [GC 66048K->53077K(251392K), 0.0959470 secs]
2 2014-01-03T12:08:38.239-0100: [GC 119125K->114661K(317440K), 0.1421720 secs]
3 2014-01-03T12:08:38.513-0100: [GC 246757K->243133K(375296K), 0.2761000 secs]

如果需要也可以同时使用两个参数。推荐同时使用这两个参数,因为这样在关联不同来源的GC日志时很有帮助

每一种收集器的日志形式都是由它们自身的实现所决定的,换而言之,每个收集器的日志格式都可以不一样。

但虚拟机设计者为了方便用户阅读,将各个收集器的日志都维持一定的共性,

例如以下两段典型的GC日志:

1
2
3
33.125: [GC [DefNew: 3324K->152K(3712K), 0.0025925 secs] 3324K->152K(11904K), 0.0031680 secs]
100.667: [Full GC [Tenured: 0K->210K(10240K), 0.0149142 secs] 4603K->210K(19456K),
[Perm : 2999K->2999K(21248K)], 0.0150007 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]

最前面的数字“33.125:”和“100.667:”代表了GC发生的时间,这个数字的含义是从Java虚拟机启动以来经过的秒数。

GC日志开头的“[GC”和“[Full GC”说明了这次垃圾收集的停顿类型,

而不是用来区分新生代GC还是老年代GC的。

如果有“Full”,说明这次GC是发生了Stop-The-World的,

例如下面这段新生代收集器ParNew的日志也会出现“[Full GC”(这一般是因为出现了分配担保失败之类的问题,所以才导致STW)。

如果是调用System.gc()方法所触发的收集,那么在这里将显示“[Full GC (System)”。

1
[Full GC 283.736: [ParNew: 261599K->261599K(261952K), 0.0000288 secs]

接下来的“[DefNew”、“[Tenured”、“[Perm”表示GC发生的区域,这里显示的区域名称与使用的GC收集器是密切相关的

例如上面样例所使用的Serial收集器中的新生代名为“Default New Generation”,所以显示的是“[DefNew”。

如果是ParNew收集器,新生代名称就会变为“[ParNew”,意为“Parallel New Generation”。

如果采用Parallel Scavenge收集器,那它配套的新生代称为“PSYoungGen”,老年代和永久代同理,名称也是由收集器决定的。

后面方括号内部的“3324K->152K(3712K)”含义是“GC前该内存区域已使用容量-> GC后该内存区域已使用容量 (该内存区域总容量)”。

而在方括号之外的“3324K->152K(11904K)”表示“GC前Java堆已使用容量 -> GC后Java堆已使用容量 (Java堆总容量)”。

再往后,“0.0025925 secs”表示该内存区域GC所占用的时间,单位是秒。

有的收集器会给出更具体的时间数据

如“[Times: user=0.01 sys=0.00, real=0.02 secs]”,
这里面的user、sys和real与Linux的time命令所输出的时间含义一致,分别代表用户态消耗的CPU时间、内核态消耗的CPU事件和操作从开始到结束所经过的墙钟时间(Wall Clock Time)。

CPU时间与墙钟时间的区别是,墙钟时间包括各种非运算的等待耗时,例如等待磁盘I/O、等待线程阻塞,而CPU时间不包括这些耗时,但当系统有多CPU或者多核的话,多线程操作会叠加这些CPU时间,所以读者看到user或sys时间超过real时间是完全正常的。

分析工具

可以使用一些离线的工具来对GC日志进行分析

比如sun的gchisto( https://java.net/projects/gchisto)

gcviewer( https://github.com/chewiebug/GCViewer ),

这些都是开源的工具,用户可以直接通过版本控制工具下载其源码,进行离线分析   

JVM参数

HotSpot JVM 提供了三类参数。
第一类包括了标准参数。顾名思义,标准参数中包括功能和输出的参数都是很稳定的,很可能在将来的JVM版本中不会改变。你可以用java命令(或者是用 java -help)检索出所有标准参数。我们在第一部分中已经见到过一些标准参数,例如:-server。

第二类是X参数,非标准化的参数在将来的版本中可能会改变。所有的这类参数都以-X开始,并且可以用java -X来检索。注意,不能保证所有参数都可以被检索出来,其中就没有-Xcomp。

第三类是包含XX参数(到目前为止最多的),它们同样不是标准的,甚至很长一段时间内不被列出来(最近,这种情况有改变 ,我们将在本系列的第三部分中讨论它们)。然而,在实际情况中X参数和XX参数并没有什么不同。X参数的功能是十分稳定的,然而很多XX参数仍在实验当中(主要是JVM的开发者用于debugging和调优JVM自身的实现)。值的一读的介绍非标准参数的文档 HotSpot JVM documentation,其中明确的指出XX参数不应该在不了解的情况下使用。这是真的,并且我认为这个建议同样适用于X参数(同样一些标准参数也是)。不管类别是什么,在使用参数之前应该先了解它可能产生的影响。
用一句话来说明XX参数的语法。所有的XX参数都以”-XX:”开始,但是随后的语法不同,取决于参数的类型。

  • 对于布尔类型的参数,我们有”+”或”-“,然后才设置JVM选项的实际名称。例如,-XX:+用于激活选项,而-XX:-用于注销选项。
  • 对于需要非布尔值的参数,如string或者integer,我们先写参数的名称,后面加上”=”,最后赋值。例如, -XX:=给赋值。
1
-XX:+PrintFlagsFinal and -XX:+PrintFlagsInitial
1
2
3
4
5
6
7
8
9
[Global flags]
uintx AdaptivePermSizeWeight = 20 {product}
uintx AdaptiveSizeDecrementScaleFactor = 4 {product}
uintx AdaptiveSizeMajorGCDecayTimeScale = 10 {product}
uintx AdaptiveSizePausePolicy = 0 {product}[...]
uintx YoungGenerationSizeSupplementDecay = 8 {product}
uintx YoungPLABSize = 4096 {product}
bool ZeroTLAB = false {product}
intx hashCode = 0 {product}

表格的每一行包括五列,来表示一个XX参数。第一列表示参数的数据类型,第二列是名称,第四列为值,第五列是参数的类别。第三列”=”表示第四列是参数的默认值,而”:=” 表明了参数被用户或者JVM赋值了。

1
-XX:+PrintCommandLineFlags

这个参数让JVM打印出那些已经被用户或者JVM设置过的详细的XX参数的名称和值。

换句话说,它列举出 -XX:+PrintFlagsFinal的结果中第三列有”:=”的参数。

以这种方式,我们可以用-XX:+PrintCommandLineFlags作为快捷方式来查看修改过的参数

监控jvm

使用自带工具就行,jstat,jmap,jstack

  • jstack主要用来查看某个Java进程内的线程堆栈信息
  • jmap用来查看堆内存使用状况,一般结合jhat使用
  • jstat利用JVM内建的指令对Java应用程序的资源和性能进行实时的命令行的监控,包括了对进程的classloader,compiler,gc情况

优化

  1. 选择合适的GC collector
  2. 整个JVM heap的大小
  3. young generation在整个JVM heap中所占的比重

参数实例

1
2
3
4
5
6
7
public static void main(String[] args) throws InterruptedException{
//通过allocateDirect分配128MB直接内存
ByteBuffer bb = ByteBuffer.allocateDirect(1024*1024*128);

TimeUnit.SECONDS.sleep(10);
System.out.println("ok");
}

测试用例1:设置JVM参数-Xmx100m,运行异常,因为如果没设置-XX:MaxDirectMemorySize,则默认与-Xmx参数值相同,分配128M直接内存超出限制范围

1
2
3
4
5
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
at java.nio.Bits.reserveMemory(Bits.java:658)
at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:306)
at com.stevex.app.nio.DirectByteBufferTest.main(DirectByteBufferTest.java:8)

#
#
为了避免Perm区满引起的full gc,建议开启CMS回收Perm区选项:

+CMSPermGenSweepingEnabled -XX:+CMSClassUnloadingEnabled

默认CMS是在tenured generation沾满68%的时候开始进行CMS收集,如果你的年老代增长不是那么快,并且希望降低CMS次数的话,可以适当调高此值:

-XX:CMSInitiatingOccupancyFraction=80

遇到两种fail引起full gc:

Prommotion failed和Concurrent mode failed时:

promotion failed是在进行Minor GC时,survivor space放不下、
对象只能放入旧生代,而此时old gen 的碎片太多为进行过内存重组和压缩,无法提供一块较大的、连续的内存空间存放来自新生代对象

Prommotion failed的日志输出大概是这样:

1
2
42576.951: [ParNew (promotion failed): 320138K->320138K(353920K), 0.2365970 secs]
42576.951: [CMS: 1139969K->1120688K( 166784K), 9.2214860 secs] 1458785K->1120688K(2520704K), 9.4584090 secs]

因为
解决这个问题的办法有两种完全相反的倾向:增大救助空间、增大年老代或者去掉救助空间。

1
2
3
解决方法可以通过设置参数
-XX:+UseCMSCompactAtFullCollection(打开对年老代的压缩)
-XX:CMSFullGCsBeforeCompaction(设置运行多少次FULL GC以后对内存空间进行压缩、整理)

1
2
直接关了servivor空间
-XX:SurvivorRatio=65536 -XX:MaxTenuringThreshold=0

concurrent mode failure

发生在当CMS已经在工作并处于concurrent阶段中,而Java堆的内存不够用需要做major GC(full GC)的时候。换句话说,old gen内存的消耗速度比CMS的收集速度要高,CMS收集器跟不上分配速度的时候会发生concurrent mode failure

Concurrent mode failed的日志大概是这样的:

1
2
(concurrent mode failure): 1228795K->1228598K(1228800K), 7.6748280 secs] 1911483K->1681165K(1911488K), 
[CMS Perm : 225407K->225394K(262144K)], 7.6751800 secs]

避免这个现象的产生就是调小-XX:CMSInitiatingOccupancyFraction参数的值,
让CMS更早更频繁的触发,降低年老代被沾满的可能。

#
#
full gc频繁说明old区很快满了。

如果是一次full gc后,剩余对象不多。那么说明你eden区设置太小,导致短生命周期的对象进入了old区

如果一次full gc后,old区回收率不大,那么说明old区太小

#
#

1
2
3
4
5
6
7
8
已知虚拟机的一些参数设置如下: 
-Xms:1G;
-Xmx:2G;
-Xmn:500M;
-XX:MaxPermSize:64M;
-XX:+UseConcMarkSweepGC;
-XX:SurvivorRatio=3;
求Eden区域的大小?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
分析这是网易2016年在线笔试题中的一道选择题。 
先分析一下里面各个参数的含义:
-Xms:1G , 就是说初始堆大小为1G
-Xmx:2G , 就是说最大堆大小为2G
-Xmn:500M ,就是说年轻代大小是500M(包括一个Eden和两个Survivor)
-XX:MaxPermSize:64M , 就是说设置持久代最大值为64M
-XX:+UseConcMarkSweepGC , 就是说使用使用CMS内存收集算法
-XX:SurvivorRatio=3 , 就是说Eden区与Survivor区的大小比值为3:1:1

题目中所问的Eden区的大小是指年轻代的大小,直接根据-Xmn:500M和-XX:SurvivorRatio=3可以直接计算得出
解500M*(3/(3+1+1))
=500M*(3/5)
=500M*0.6
=300M
所以Eden区域的大小为300M。

#
#
-Xmn200m -server

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
jmap -heap pid

Heap Configuration:
MinHeapFreeRatio = 0
MaxHeapFreeRatio = 100
MaxHeapSize = 4282384384 (4084.0MB)
NewSize = 209715200 (200.0MB)
MaxNewSize = 209715200 (200.0MB)
OldSize = 58720256 (56.0MB)
NewRatio = 2
SurvivorRatio = 8
MetaspaceSize = 21807104 (20.796875MB)
CompressedClassSpaceSize = 1073741824 (1024.0MB)
MaxMetaspaceSize = 17592186044415 MB
G1HeapRegionSize = 0 (0.0MB)

Heap Usage:
PS Young Generation
Eden Space:
capacity = 92274688 (88.0MB)
used = 37931936 (36.174713134765625MB)
free = 54342752 (51.825286865234375MB)
41.107628562233664% used
From Space:
capacity = 58720256 (56.0MB)
used = 20305320 (19.364662170410156MB)
free = 38414936 (36.635337829589844MB)
34.57975387573242% used
To Space:
capacity = 56623104 (54.0MB)
used = 0 (0.0MB)
free = 56623104 (54.0MB)
0.0% used
PS Old Generation
capacity = 176160768 (168.0MB)
used = 65785064 (62.737525939941406MB)
free = 110375704 (105.2624740600586MB)
37.34376544044132% used

发现为什么from space与to space不对呢。应该是20M才对

1
2
3
4
5
6
7
8
-server默认使用ParallelScavenge系的GC

HotSpot VM里,ParallelScavenge系的GC(UseParallelGC / UseParallelOldGC)
默认行为是SurvivorRatio如果不显式设置就没啥用。
显式设置到跟默认值一样的值则会有效果

因为ParallelScavenge系的GC最初设计就是默认打开AdaptiveSizePolicy的,
它会自动、自适应的调整各种参数
1
需要显示配置 -XX:SurvivorRatio=8

参考资料

http://yinwufeng.iteye.com/blog/2157787

http://itindex.net/detail/47030-cms-gc-%E9%97%AE%E9%A2%98

http://www.cnblogs.com/ityouknow/p/5614961.html

motan服务端

发表于 2017-02-06 | 分类于 源码解读
字数统计: 1.8k 字数 | 阅读时长 ≈ 8 分钟

前引

新年回来上班,记录下服务端的处理过程,就当热个身

服务端的处理也有套路,不管上层怎么玩,最后还得是通过反射得到Method对象,再调用invoke()

image
根据这张序列图,可以把服务端分为两部分

  1. NettyServer前面的算一部分,搭基础构建Exporter对象
  2. nettyserver后面的算一部分,找到对应method,invoke,通过网络返回

构建Exporter对象

结合spring

其实在《motan客户端》时有提过,但没有深究;spring扩展自定义xml是个很老的技术了
spring扩展xml文档

spring通过XML解析程序将其解析为DOM树,通过NamespaceHandler指定对应的Namespace的BeanDefinitionParser将其转换成BeanDefinition。再通过Spring自身的功能对BeanDefinition实例化对象。

在期间,Spring还会加载两项资料:

  1. META-INF/spring.handlers

指定NamespaceHandler(实现org.springframework.beans.factory.xml.NamespaceHandler)接口,或使用org.springframework.beans.factory.xml.NamespaceHandlerSupport的子类。

  1. META-INF/spring.schemas

在解析XML文件时将XSD重定向到本地文件,避免在解析XML文件时需要上网下载XSD文件。通过现实org.xml.sax.EntityResolver接口来实现该功能。

ConfigHandler

解析完xml后,服务器通过ServiceConfigBean来监听spring容器加载完成。

1
2
3
4
5
6
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
if (!getExported().get()) {
export();
}
}

调用export(),构建Exporter;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public synchronized void export() {
if (exported.get()) {
LoggerUtil.warn(String.format("%s has already been expoted, so ignore the export request!", interfaceClass.getName()));
return;
}

checkInterfaceAndMethods(interfaceClass, methods);

List<URL> registryUrls = loadRegistryUrls();
if (registryUrls == null || registryUrls.size() == 0) {
throw new IllegalStateException("Should set registry config for service:" + interfaceClass.getName());
}

Map<String, Integer> protocolPorts = getProtocolAndPort();
for (ProtocolConfig protocolConfig : protocols) {
Integer port = protocolPorts.get(protocolConfig.getId());
if (port == null) {
throw new MotanServiceException(String.format("Unknow port in service:%s, protocol:%s", interfaceClass.getName(),
protocolConfig.getId()));
}
doExport(protocolConfig, port, registryUrls);
}

afterExport();
}

这个方法主要就做了两件事

  1. loadRegistryUrls(),根据motan:registry,生成URL对象。URL也算是整个框架的核心,一个url包含了配置中的所有内容。就像一个领域对象一样。

如果使用的是zookeeper,对就的URL就是:zookeeper://127.0.0.1:2181/com.weibo.api.motan.registry.RegistryService?group=default_rpc

这是注册中心的URL

而服务URL是以参数为embed为key包含在里面,里面包含了所有的服务参数

motan://127.0.0.1:8002/com.share.rpc.service.DEMOService?module=match-rpc&loadbalance=activeWeight&nodeType=service&accessLog=true&minWorkerThread=2&protocol=motan&isDefault=true&maxWorkerThread=10&refreshTimestamp=1486448597095&id=com.weibo.api.motan.config.springsupport.ServiceConfigBean&export=protocolMatch:8002&requestTimeout=60000&group=match-rpc&

  1. doExport()
    1
    2
    ConfigHandler configHandler = ExtensionLoader.getExtensionLoader(ConfigHandler.class).getExtension(MotanConstants.DEFAULT_VALUE);
    exporters.add(configHandler.export(interfaceClass, ref, urls));

到这儿就是委托给ConfigHandler处理了。

默认的实现类:SimpleConfigHandler

这个类里面有两个重要的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
public <T> T refer(Class<T> interfaceClass, List<Cluster<T>> clusters, String proxyType) {
ProxyFactory proxyFactory = ExtensionLoader.getExtensionLoader(ProxyFactory.class).getExtension(proxyType);
return proxyFactory.getProxy(interfaceClass, new RefererInvocationHandler<T>(interfaceClass, clusters));
}

@Override
public <T> Exporter<T> export(Class<T> interfaceClass, T ref, List<URL> registryUrls) {

String serviceStr = StringTools.urlDecode(registryUrls.get(0).getParameter(URLParamType.embed.getName()));
URL serviceUrl = URL.valueOf(serviceStr);

// export service
// 利用protocol decorator来增加filter特性
String protocolName = serviceUrl.getParameter(URLParamType.protocol.getName(), URLParamType.protocol.getValue());
Protocol protocol = new ProtocolFilterDecorator(ExtensionLoader.getExtensionLoader(Protocol.class).getExtension(protocolName));
Provider<T> provider = new DefaultProvider<T>(ref, serviceUrl, interfaceClass);
Exporter<T> exporter = protocol.export(provider, serviceUrl);

// register service
register(registryUrls, serviceUrl);

return exporter;
}

refer()就是RefererConfig配置完后的调用的方法,就看到了客户端的核心类RefererInvocationHandler

export()就是服务端使用的方法了。

构造provider

1
2
3
4
public interface Provider<T> extends Caller<T> {

Class<T> getInterface();
}

其实就是外面服务类的代理类,里面就一个服务类的引用。在初始化时,把所有的方法全部缓存

1
2
3
4
5
6
7
8
private void initMethodMap(Class<T> clz) {
Method[] methods = clz.getMethods();

for (Method method : methods) {
String methodDesc = ReflectUtil.getMethodDesc(method);
methodMap.put(methodDesc, method);
}
}

Protocol的export就是启动一个netty server,提供对外服务。

register 注册服务

按上面export(),先启动一个netty server,服务一切就绪后,再向注册中心进行注册了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void register(List<URL> registryUrls, URL serviceUrl) {

for (URL url : registryUrls) {
// 根据check参数的设置,register失败可能会抛异常,上层应该知晓
RegistryFactory registryFactory = ExtensionLoader.getExtensionLoader(RegistryFactory.class).getExtension(url.getProtocol());
if (registryFactory == null) {
throw new MotanFrameworkException(new MotanErrorMsg(500, MotanErrorMsgConstant.FRAMEWORK_REGISTER_ERROR_CODE,
"register error! Could not find extension for registry protocol:" + url.getProtocol()
+ ", make sure registry module for " + url.getProtocol() + " is in classpath!"));
}
Registry registry = registryFactory.getRegistry(url);
registry.register(serviceUrl);
}
}

根据注册协议选择注册中心
RegistryFacotry有三种:1. direct 2.local 3.zookeeper

对应的registry也是这三种

在zookeeperregistry里面,

1
2
3
4
5
6
7
8
9
10
11
12
13
protected void doRegister(URL url) {
try {
serverLock.lock();
// 防止旧节点未正常注销
removeNode(url, ZkNodeType.AVAILABLE_SERVER);
removeNode(url, ZkNodeType.UNAVAILABLE_SERVER);
createNode(url, ZkNodeType.UNAVAILABLE_SERVER);
} catch (Throwable e) {
throw new MotanFrameworkException(String.format("Failed to register %s to zookeeper(%s), cause: %s", url, getUrl(), e.getMessage()), e);
} finally {
serverLock.unlock();
}
}

这儿注册后,这个服务还是unavailable,需要调用
MotanSwitcherUtil.setSwitcherValue(MotanConstants.REGISTRY_HEARTBEAT_SWITCHER, true);才能变成available

NettyServer

一样的套路,有一个默认ChannelHandler,其实什么事都不干,只是包装一下自定义Handler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
final NettyChannelHandler handler = new NettyChannelHandler(NettyServer.this, messageHandler,
standardThreadExecutor);

bootstrap.setPipelineFactory(new ChannelPipelineFactory() {
// FrameDecoder非线程安全,每个连接一个 Pipeline
public ChannelPipeline getPipeline() {
ChannelPipeline pipeline = Channels.pipeline();
pipeline.addLast("channel_manage", channelManage);
pipeline.addLast("decoder", new NettyDecoder(codec, NettyServer.this, maxContentLength));
pipeline.addLast("encoder", new NettyEncoder(codec, NettyServer.this));
pipeline.addLast("handler", handler);
return pipeline;
}
});

这儿有个有意思的MessageHandler:ProviderProtectedMessageRouter
从名字看,有保护功能

1) 如果接口只有一个方法,那么直接return true
2) 如果接口有多个方法,那么如果单个method超过 maxThread / 2 && totalCount > (maxThread 3 / 4),那么return false;
3) 如果接口有多个方法(4个),同时总的请求数超过 maxThread
3 / 4,同时该method的请求数超过 maxThead * 1 / 4, 那么return false
4) 其他场景return true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
protected boolean isAllowRequest(int requestCounter, int totalCounter, int maxThread, Request request) {
if (methodCounter.get() == 1) {
return true;
}

// 该方法第一次请求,直接return true
if (requestCounter == 1) {
return true;
}

// 不简单判断 requsetCount > (maxThread / 2) ,因为假如有2或者3个method对外提供,
// 但是只有一个接口很大调用量,而其他接口很空闲,那么这个时候允许单个method的极限到 maxThread * 3 / 4
if (requestCounter > (maxThread / 2) && totalCounter > (maxThread * 3 / 4)) {
return false;
}

// 如果总体线程数超过 maxThread * 3 / 4个,并且对外的method比较多,那么意味着这个时候整体压力比较大,
// 那么这个时候如果单method超过 maxThread * 1 / 4,那么reject
return !(methodCounter.get() >= 4 && totalCounter > (maxThread * 3 / 4) && requestCounter > (maxThread * 1 / 4));

}

拒绝的结果就是放一个异常:

1
2
3
4
5
6
7
8
9
10
11
private Response reject(String method, int requestCounter, int totalCounter, int maxThread) {
DefaultResponse response = new DefaultResponse();
MotanServiceException exception =
new MotanServiceException("ThreadProtectedRequestRouter reject request: request_counter=" + requestCounter
+ " total_counter=" + totalCounter + " max_thread=" + maxThread, MotanErrorMsgConstant.SERVICE_REJECT);
exception.setStackTrace(new StackTraceElement[0]);
response.setException(exception);
LoggerUtil.error("ThreadProtectedRequestRouter reject request: request_method=" + method + " request_counter=" + requestCounter
+ " =" + totalCounter + " max_thread=" + maxThread);
return response;
}

other

RpcContext

1
2
3
4
5
6
7
8
9
private static final ThreadLocal<RpcContext> localContext = new ThreadLocal<RpcContext>() {
protected RpcContext initialValue() {
return new RpcContext();
}
};

public static RpcContext getContext() {
return localContext.get();
}

这个ThreadLocal尽然还可以设置默认初始值,以前尽然没用过

总结

服务端相对客户端还是很简单的。

没有ha,loadbalance,就是原生netty就把请求处理完了。

motan客户端

发表于 2017-01-20 | 分类于 源码解读
字数统计: 1.7k 字数 | 阅读时长 ≈ 7 分钟

RPC的本质

方法调用对于程序员来讲是再正常不过的事了,object.method(),RPC的使用也一样,但底层对这一过程又切分开,有client和server两端,也就是调用者与实现者

因为他们不再在同一进程中,需要通过网络跨JVM实现这一调用过程

在java中的实现手法:动态代理+socket通信;这就是个套路,上层怎么封装实现,但底层就是这样,概莫能外

请求过程

motan的调用实现

先画个简单的序列图,理清一下调用过程
image

motan与spring的结合,后面再写了,spring的扩展也很简单。

基于对RPC本质的认识,可以先找到InvocationHandler的实现类RefererInvocationHandler

这个接口就一个方法

1
2
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable;

在这个方法里面就是去构造socket传输的request对象,request主要就是方法的签名信息与参数,传输到server端,去执行对应的实现方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if(isLocalMethod(method)){
if("toString".equals(method.getName())){
return clustersToString();
}
throw new MotanServiceException("can not invoke local method:" + method.getName());
}
DefaultRequest request = new DefaultRequest();

request.setRequestId(RequestIdGenerator.getRequestId());
request.setArguments(args);
request.setMethodName(method.getName());
request.setParamtersDesc(ReflectUtil.getMethodParamDesc(method));
request.setInterfaceName(clz.getName());
request.setAttachment(URLParamType.requestIdFromClient.getName(), String.valueOf(RequestIdGenerator.getRequestIdFromClient()));

// 当 referer配置多个protocol的时候,比如A,B,C,
// 那么正常情况下只会使用A,如果A被开关降级,那么就会使用B,B也被降级,那么会使用C
for (Cluster<T> cluster : clusters) {
String protocolSwitcher = MotanConstants.PROTOCOL_SWITCHER_PREFIX + cluster.getUrl().getProtocol();

Switcher switcher = switcherService.getSwitcher(protocolSwitcher);

if (switcher != null && !switcher.isOn()) {
continue;
}

request.setAttachment(URLParamType.version.getName(), cluster.getUrl().getVersion());
request.setAttachment(URLParamType.clientGroup.getName(), cluster.getUrl().getGroup());
// 带上client的application和module
request.setAttachment(URLParamType.application.getName(), ApplicationInfo.getApplication(cluster.getUrl()).getApplication());
request.setAttachment(URLParamType.module.getName(), ApplicationInfo.getApplication(cluster.getUrl()).getModule());
Response response = null;
boolean throwException =
Boolean.parseBoolean(cluster.getUrl().getParameter(URLParamType.throwException.getName(),
URLParamType.throwException.getValue()));
try {
//真正执行
response = cluster.call(request);
return response.getValue();
}

invoke交给了Cluster.call,而Cluster又给了HAStragy.call,HA策略通过loadbalance选择负载均衡策略得到Referer

Cluster是什么

在InvocationHandler里面,调用了Cluster的call方法,从代码上看,它的本质就是Referer的集合,并且提供了HA服务以及负载均衡。而Referer是提供服务的一个抽象

HA与LoadBalance

image

HA

HA策略,就提供了两种,

fail-fast

fail-fast很简单,调用失败就抛异常;

fail-over

fail-over相对fail-fast多了重试次数,如果失败,就重试一个referer

LoadBalance

这倒提供了不少

Round-Robin

这个很简单,一个一个往下轮询就行了,
但需要记住上一次的位置

random

随机

Least Load

这个motan实现有点意思

由于Referer List可能很多,比如上百台,如果每次都要从这上百个Referer或者最低并发的几个,性能有些损耗,因此 random.nextInt(list.size())获取一个起始的index,然后获取最多不超过MAX_REFERER_COUNT的 状态是isAvailable的referer进行判断activeCount.

localFirst

本地服务优先获取策略:对referers根据ip顺序查找本地服务,多存在多个本地服务,获取Active最小的本地服务进行服务。当不存在本地服务,但是存在远程RPC服务,则根据ActivWeight获取远程RPC服务;当两者都存在,所有本地服务都应优先于远程服务,本地RPC服务与远程RPC服务内部则根据ActiveWeight进行

NettyClient

上层不管怎么选择服务,最后都需要传输层去传输,nettyclient就是传输作用。

在DefaultRpcReferer中创建了一个nettyClient。向server发送远程调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private Response request(Request request, boolean async) throws TransportException {
Channel channel = null;

Response response = null;

try {
// return channel or throw exception(timeout or connection_fail)
channel = borrowObject();

if (channel == null) {
LoggerUtil.error("NettyClient borrowObject null: url=" + url.getUri() + " "
+ MotanFrameworkUtil.toString(request));
return null;
}

// async request
response = channel.request(request);
// return channel to pool
returnObject(channel);

使用了common-pool连接池

在这儿是委托给了nettychannel.request(),nettyclient与nettychannel是什么关系呢?
client有server地址,channel就是这个地址连接的通道。
在nettychannel中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public Response request(Request request) throws TransportException {
int timeout = nettyClient.getUrl().getMethodParameter(request.getMethodName(), request.getParamtersDesc(),
URLParamType.requestTimeout.getName(), URLParamType.requestTimeout.getIntValue());
if (timeout <= 0) {
throw new MotanFrameworkException("NettyClient init Error: timeout(" + timeout + ") <= 0 is forbid.",
MotanErrorMsgConstant.FRAMEWORK_INIT_ERROR);
}
NettyResponseFuture response = new NettyResponseFuture(request, timeout, this.nettyClient);
this.nettyClient.registerCallback(request.getRequestId(), response);

ChannelFuture writeFuture = this.channel.write(request);

boolean result = writeFuture.awaitUninterruptibly(timeout, TimeUnit.MILLISECONDS);

if (result && writeFuture.isSuccess()) {
response.addListener(new FutureListener() {
@Override
public void operationComplete(Future future) throws Exception {
if (future.isSuccess() || (future.isDone() && ExceptionUtil.isBizException(future.getException()))) {
// 成功的调用
nettyClient.resetErrorCount();
} else {
// 失败的调用
nettyClient.incrErrorCount();
}
}
});
return response;
}


到此,整个请求过程已经完成。

返回处理

调用完成之后,总得得到结果才行

motan返回过程

在上面nettychannel.request方法,会返回一个response,NettyResponseFuture这个类名就说明了一切,使用了Future模式。

在返回response时,构造真实response

1
2
3
4
5
6
7
private Response asyncResponse(Response response, boolean async) {
if (async || !(response instanceof NettyResponseFuture)) {
return response;
}

return new DefaultResponse(response);
}

真实response里面,使用futureresponse去取值

1
2
3
4
5
6
7
public DefaultResponse(Response response) {
this.value = response.getValue();
this.exception = response.getException();
this.requestId = response.getRequestId();
this.processTime = response.getProcessTime();
this.timeout = response.getTimeout();
}

在futureresponse里面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public Object getValue() {
synchronized (lock) {
if (!isDoing()) {
return getValueOrThrowable();
}

if (timeout <= 0) {
try {
lock.wait();
} catch (Exception e) {
cancel(new MotanServiceException("NettyResponseFuture getValue InterruptedException : "
+ MotanFrameworkUtil.toString(request) + " cost="
+ (System.currentTimeMillis() - createTime), e));
}

// don't need to notifylisteners, because onSuccess or
// onFailure or cancel method already call notifylisteners
return getValueOrThrowable();
} else {
long waitTime = timeout - (System.currentTimeMillis() - createTime);

if (waitTime > 0) {
for (;;) {
try {
lock.wait(waitTime);
} catch (InterruptedException e) {
}

if (!isDoing()) {
break;
} else {
waitTime = timeout - (System.currentTimeMillis() - createTime);
if (waitTime <= 0) {
break;
}
}
}
}

if (isDoing()) {
timeoutSoCancel();
}
}
return getValueOrThrowable();
}
}

没有使用java.util.concurrent包中Condition,CountDownLatch之类的工具类,而是使用原始的wait,notify组合

在NettyClient中,得到返回对象后,对responsefuter进行赋值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
pipeline.addLast("handler", new NettyChannelHandler(NettyClient.this, new MessageHandler() {
@Override
public Object handle(Channel channel, Object message) {
//得到返回对象
Response response = (Response) message;
//得到对应request的future
NettyResponseFuture responseFuture = NettyClient.this.removeCallback(response.getRequestId());

if (responseFuture == null) {
LoggerUtil.warn(
"NettyClient has response from server, but resonseFuture not exist, requestId={}",
response.getRequestId());
return null;
}

if (response.getException() != null) {
responseFuture.onFailure(response);
} else {
responseFuture.onSuccess(response);
}

return null;
}
}));

responseFuture的onsuccess方法,进行赋值并notify

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void onSuccess(Response response) {
this.result = response.getValue();
this.processTime = response.getProcessTime();

done();
}
private boolean done() {
synchronized (lock) {
if (!isDoing()) {
return false;
}

state = FutureState.DONE;
lock.notifyAll();
}

notifyListeners();
return true;
}


总结

到此,客户端部分已经完成,主要就是两方面

  1. 调用请求
  2. 返回处理

还有一些问题:

  1. 客户端怎么服务发现的?
  2. 服务降低怎么处理的?

motan扩展机制

发表于 2017-01-08 | 分类于 源码解读
字数统计: 1.3k 字数 | 阅读时长 ≈ 5 分钟

motan第二篇,本来想写motan的rpc调用过程的,但项目中的需求需要对motan进行扩展,所以就先记录下

引导

在写一个框架或者在项目中提供一些底层服务时,都会有这种情况,会有一些默认的实现,但你知道这些默认实现只是很满足很基本的自身需求,开发人员可能会扩展,想自定义一个实现

处理方式

  1. 提供一个设置实现类的setter,开发者在初始化时调用一下
  2. 提供配置入口,给个key,配置上自定义类名
  3. 类似slf4j一样,提供桥接类

SPI

motan使用了spi的方式

SPI(Service Provider Interface),服务提供接口;也是一种服务发现机制

1
2
3
4
5
6
7
8
9
系统里抽象的各个模块,往往有很多不同的实现方案,
比如日志模块的方案,xml解析模块、jdbc模块的方案等。面向的对象的设计里,
我们一般推荐模块之间基于接口编程,模块之间不对实现类进行硬编码
。一旦代码里涉及具体的实现类,就违反了可拔插的原则,
如果需要替换一种实现,就需要修改代码。

为了实现在模块装配的时候能不在程序里动态指明,这就需要一种服务发现机制。
java spi就是提供这样的一个机制:为某个接口寻找服务实现的机制。
有点类似IOC的思想,就是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要。

在JDK6之前,你可能会自己定义一种服务提供的约定,在JDK6之后,java也提供了标准约定

1
2
扩展者在jar包的META-INF/services/目录下放置与接口同名的文本文件 
内容为接口实现类名,多个实现类名用换行符分隔

java.util.ServiceLoader类来实现从配置文件中加载子类或者接口的实现类

SPI与API的区别

1
2
3
4
5
6
7
8
9
10
11
12
What is the difference between Service Provider Interface (SPI) and Application Programming Interface (API)?
More specifically, for Java libraries, what makes them an API and/or SPI?
the API is the description of classes/interfaces/methods/...
that you call and use to achieve a goal
the SPI is the description of classes/interfaces/methods/...
that you extend and implement to achieve a goal
Put differently, the API tells you what a specific class/method does for you and the SPI tells you what you must do to conform.
Sometimes SPI and API overlap.
For example in JDBC the Driver class is part of the SPI:
If you simply want to use JDBC, you don't need to use it directly, but everyone who implements a JDBC driver must implement that class.
The Connection interface on the other hand is both SPI and API:
You use it routinely when you use a JDBC driver and it needs to be implemented by the developer of the JDBC driver。

JDK spi

使用jdk自带的ServiceLoader写一个示例:

一个接口

1
2
3
4
public interface Spi {

public void provide();
}

一个实现类

1
2
3
4
5
6
7
public class DefaultSpi implements Spi{

@Override
public void provide() {
System.out.println("默认spi实现");
}
}

入口类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 一个spi的demo
*
*/
public class SpiDemoMain
{
public static void main( String[] args )
{
ServiceLoader<Spi> spiServiceLoader = ServiceLoader.load(Spi.class);

Iterator<Spi> spiIterator = spiServiceLoader.iterator();

while ( spiIterator.hasNext()) {
spiIterator.next().provide();
}
}
}

在META-INF文件夹里面新建个services文件夹,在services文件夹里面新建一个
com.jjk.spi.Spi文件

完整的代码可从https://github.com/zhuxingsheng/spidemo下载

ServiceLoader源码解析

原理很简单,一个类实现这个SPI机制

它的本质,也就是从某个地方加载服务实现类,文件名是服务接口名

定义存放文件的地方

1
private static final String PREFIX = "META-INF/services/";

类内部使用了延迟加载LazyIterator,在使用到了实现类时,才去实例化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private S nextService() {
if (!hasNextService())
throw new NoSuchElementException();
String cn = nextName;
nextName = null;
Class<?> c = null;
try {

c = Class.forName(cn, false, loader);
} catch (ClassNotFoundException x) {
fail(service,
"Provider " + cn + " not found");
}
if (!service.isAssignableFrom(c)) {
fail(service,
"Provider " + cn + " not a subtype");
}
try {
// 调用next方法时,才实例化
S p = service.cast(c.newInstance());
providers.put(cn, p);
return p;
} catch (Throwable x) {
fail(service,
"Provider " + cn + " could not be instantiated",
x);
}
throw new Error(); // This cannot happen
}

motan spi

motan的spi,跟java spi差不多,但做了一些加强

先看官方文档

  1. 实现SPI扩展点接口
  1. 实现类增加注解
    @Spi(scope = Scope.SINGLETON) //扩展加载形式,单例或多例
    @SpiMeta(name = “motan”) //name表示扩展点的名称,根据name加载对应扩展
    @Activation(sequence = 100) //同类型扩展生效顺序,部分扩展点支持。非必填
    增加SPI实现声明 ${classpath}/MATA-INF/services/${SPI interface fullname}文件中添加对应SPI接口实现类全名。 可参照motan-core模块/MATA-INF/services/下的配置

主要类就是com.weibo.api.motan.core.extension.ExtensionLoader
代码在https://github.com/zhuxingsheng/motan/blob/master/motan-core/src/main/java/com/weibo/api/motan/core/extension/ExtensionLoader.java#L100-99
其实代码很简单,不需要额外的解读,都能看明白

还有几个注解类

@Spi 指定类生命周期

@SpiMeta 给类一个别名,方便配置时使用

motan入门

发表于 2017-01-04 | 分类于 源码解读
字数统计: 324 字数 | 阅读时长 ≈ 1 分钟

motan weibo的RPC框架,Motan是一套高性能、易于使用的分布式远程服务调用(RPC)框架

这次在项目中引入了此框架。

在使用中学习。研读下源码。记录下使用学习过程。

RPC原理

image

什么是Stub?

Stub是一段代码,用来转换RPC过程中传递的参数。处理内容包括不同OS之间的大小端问题。另外,Client端一般叫Stub,Server端一般叫Skeleton。

生产方式:1)手动生成,比较麻烦;2)自动生成,使用IDL(InterfaceDescriptionLanguate),定义C/S的接口。

交互机制标准:一般采用IDL,生成IDL的工具 RPCGEN()。

为什么引入motan

引入它,主要是因为它能满足项目需求;

  1. 它比较牛,支撑了整个weibo
  2. 集成了spring,基本无侵入
  3. 具有集群功能,支持zookeeper

还有别的优点了,官方文档写得很漂亮,但这几点已经足以吸引我

motan demo

直接官方示例吧,也可以

fork me

我会在阅读源码的过程中,加上注释

The clean coder 读书笔记

发表于 2016-12-11 | 分类于 读书
字数统计: 1.9k 字数 | 阅读时长 ≈ 6 分钟

这本书不厚,但都是干货。

在前言中,就写了本书的目的:

  1. 什么是软件专业人士
  2. 软件专业人士如何行事
  3. 软件专业人士如何处理冲突,应对很紧的工期,如何和不讲道理的管理人员打交道
  4. 软件专业人士何时应该说”不”?怎么说?
  5. 软件专业人士如何应对压力?

这些问题,也正是一名软件开发人员需要去了解的问题。

所谓专业人士:就是能对自己犯下错误负责的人,哪怕那些错误实际上在所难免

专业人士有哪些特性呢?

  1. 不担当,比如代码测试的全覆盖
  2. 不随便承诺
  3. 不会阻止别人修改自己的代码
  4. 满足雇主的需求
  5. 需要专门训练提升自己的技能

摘录一些干货,再对比一下当下的开发环境,那是相当贴切

为什么大多数开发人员不敢不断修改他的代码呢?因为他们害怕会改坏代码!为什么会有这样的担心呢?因为他们没有做过测试

这个对于现在的开发环境太真实了,在一个项目上线后,其实在开发中后期,就已经出现这样的情况

  1. 一个方法,代码很乱,逻辑看着有点不通,出于对代码整洁的追求,重构起来,结果发布之后,出现了bug,原先功能已经不能正常运作了,而这个bug,得等很多才会被发现,因为这个功不在本期开发版本计划中,过了测试期了
  2. 一个方法里面有行代码,看似无用,删除了吧,结果又出了一个隐形的bug

正是以上问题,所以项目中的人越来越不敢动代码,不能删,不能改,只能加,有新需求,就只能加点代码,只要能正常工作,就不要管别的代码了,管了就得挂。

这就是完全没有测试的问题。

要用这些自动化单元测试去测多少代码呢?还要说吗?全部!全部都要测

这一种方法,就是TDD,来规避这个问题。

作者是TDD的践行者

如果连所有代码是否都可以正常运行都不知道,还算什么专业人士?

如果说多年前,很多人对TDD有疑问,当然现在还是有人有疑问,有疑问的原因,就是你还没有践行TDD。

对于TDD:

  1. 此事已有定论
  2. 争论已经结束
  3. GOTO是有害的
  4. TDD确实可行

有此人事先写代码,事后再写测试,这种方法相对于TDD来讲呢?

如果很仔细地来看,也许后写测试还可以达到较高的覆盖率真。但是事后写的测试只是一种防守。而先行编写的测试则是进攻,事后编写测试的作者已经受制于已有代码,他已经知道问题是如何解决的。与采用测试先行的方式编写的测试代码比起来,后写的测试在深度和捕获错误的灵敏度方面要逊色很多

一个开发方法,业界已经得到普遍认可,但国内有多少公司实施了?至少我所经历的公司都没有。
聪明的开发者们宁可在出了问题花大量的精力去一步步调试,也不愿花一点点的时间去写单元测试,执行TDD。

曾经在读云风博客时,发现一段有共鸣的话

反感围绕着调试的开发方式,也是不断的在测试,试错,纠正的循环中奔波,好的程序员应该努力的在编写的过程中,在头脑中排错,在预感到坏味道时,就赶紧重写,而坏味道就是代码陷入了复杂度太高的境地,无法一眼看潜在的问题。对付复杂度最好的武器是简化代码

在遇到bug时,应该仔细浏览代码,设想各种出错的可能。而不是将错误代码运行起来,查看运行中的状态变化

这段话中不赞成的解决问题方式,其实是很多开发人员普遍具备的。

作者在对编码也有相似的体会

我发现,要精熟掌握每项技艺,关键都是要具备“信心”和“出错感知”能力。

我想作者的“出错感知” 与 云风的“预感到坏味道” 是一样的

作者给了一套了编码规则与原则

  • 做好准备

    1. 快速响应,做出第一版本
    2. 深入需求分析
    3. 插件式,低耦合
    4. 可读性,可维护性

    当感到疲劳,焦虑的时候,千万不要写代码

  • 流态区

    避免进入流态区

    这种意识状态并非真的极为高效,也绝非毫无错误。这其实只是一种“浅层冥想”状态,在这种状态下,为了追求所谓的速度,理性思考的能力会下降

其实这不是我第一次听到的建议,不要进入流态区,只是一种精神酸爽,对于实质工作没有意义,因为进入了这种状态会出现只见树木不见森林,当时流线性的写出代码,事后经常会发现偏离了方向,不得不重写。

  • 中断

    作者给出了两种方法

    1. 结对编程,让搭档维护住中断的上下文
    2. TDD,失败的测试能帮你维护住编码进度的上下文
  • 阻塞

    当情绪低落,焦虑,恐惧时,最好的方法:找一个搭档结对编程

作者是一个TDD以及结对编程的提倡与践行人,在书可你可以看到很多次作者对这两种方法的推荐,可惜国内很少!

书中有两个章节与近期订阅的李笑来老师文章有重合

  1. 练习

任何事情,只要想做得快,都离不开练习。
两个武者搏斗,每个人都必须能够迅速识别出对方的意图,并且百分之一秒内正确应对。在搏斗中,你不可能有充足的时间来研究架势,思考如何应对,这时候你只能依靠身体的反应。实际上,真正做出反应的是你的身体,大脑是在更高级的层面上思考

也就是李笑来老师提的刻意练习,如果程序员不去刻意练习,写各种demo,那么我想水平永远是hello world的水平了

  1. 注意力

    注意力是稀缺的资源,它类似魔力点数,如果你用光了自己的注意力点数,必须花一个小时或更多的时间都不需要注意的事情,来补充它。

    这是时间管理时提到的,李笑来也讲,注意力是你最宝贵的财富

    时间在本质上不属于你,你只能试着与它做朋友,让它为你所用。你的注意力才是你所拥有最重要、最宝贵的资源。你可以自己作主,要把它放在“成长”上。

1…1314
朱兴生

朱兴生

140 日志
3 分类
52 标签
© 2016 — 2022 朱兴生 | Site words total count: 323.5k
由 Hexo 强力驱动
|
主题 — NexT.Mist v5.1.4
沪ICP备18040647号-1