打怪升级容器关于Map
Posted fisher
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了打怪升级容器关于Map相关的知识,希望对你有一定的参考价值。
关于Map接口,具体的实现有HashMap、HashTable、TreeMap等
-
HashMap
老规矩,如果我们要看源码,我们要从这么几点去看:它的继承结构、它的核心实现能力。我们知道hashMap是一个kv容器,那么它的实现其实主要取决于这几点:
1.存放 如何处理hash冲突 怎么存?
2.获取 怎么通过key获取?
3.扩容 什么时候 什么条件会扩容?
4.删除 怎么样删除一个元素
非常简洁的继承结构,除了序列化和拷贝支持还继承了AbstractMap复用key,value结构的部分通用功能。
我们先来看一下hashmap的结构
首先说明一下,(n - 1) % hash 和 (n - 1) & hash,(2^h - 1) & hash 是等价的,其中n = 2^h,后者运算效率更高。
通过hashMap的继承结构,我们可以发现首先它实现了上层Map,并且实现了AbstractMap,HashMap内部采用的是数组+链表的结构,作为元素的存放,在存入的时候会先进行 (n - 1) & hash。
HashMap采用了数组加链表的结构,在存入元素的时候,会先通过 (n - 1) & hash获取hash存储的位置,这个位置就是key存放的位置,其实就是(n - 1) % hash,n相当于数组的长度,因为要&运算,所以长度必须是2的n次幂。
只有当hash发生了冲突,元素的内容不一样,但是hash值一样,这时候两个元素通过 (n - 1) & hash均放在了同一个位置,这时候才会在链表中放入元素。
那么。在hashMap中,Node又是用来干什么的呢?
数组中每个元素都被包装成了Node,其内部有四个属性,详情看源码:
/* ---------------- Fields -------------- */ /** * The table, initialized on first use, and resized as * necessary. When allocated, length is always a power of two. * (We also tolerate length zero in some operations to allow * bootstrapping mechanics that are currently not needed.) */
//这个就是存储元素的数组
transient Node<K,V>[] table;
static class Node<K,V> implements Map.Entry<K,V>
//key hash final int hash;
// 元素的key final K key;
//元素的value V value;
//下一个node指针 Node<K,V> next; Node(int hash, K key, V value, Node<K,V> next) this.hash = hash; this.key = key; this.value = value; this.next = next; public final K getKey() return key; public final V getValue() return value; public final String toString() return key + "=" + value; public final int hashCode() return Objects.hashCode(key) ^ Objects.hashCode(value); public final V setValue(V newValue) V oldValue = value; value = newValue; return oldValue; public final boolean equals(Object o) if (o == this) return true; if (o instanceof Map.Entry) Map.Entry<?,?> e = (Map.Entry<?,?>)o; if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue())) return true; return false;
当发生了hash冲突、元素内容不一样但是hash一样,才会在链表中放入元素,数组中的元素被Node进行包装,内部分别是key、value、hash、和next指针。
那么,hashmap是如何进行hash计算的呢?
static final int hash(Object key) int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
key的hash计算并没有我们想想的那么复杂,首先null的hash永远为0,其次是将元素本身的hashCode取反,再与高16位异或计算。我们知道hashCode返回的是int值,所以最大就是32个字节。由于计算下标的时候是,(n-1) & hash。所以当n - 1,比较小的时候,只能与hash 的低位计算。比如数字 786431 和 1835007 的低位全都是1,但是他们的高位却不相同。这个时候让高位参与计算,会进一步减少碰撞的概率。
那么了解到hashmap的基本结构后,我们要从源码的结构和方法进一步了解它
首先看看HashMap的成员变量
//默认的数组长度 16 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //数组最大长度 1073741824 static final int MAXIMUM_CAPACITY = 1 << 30; //默认的扩容因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; //当链表的长度大于这个数字的时候,会转换成数结构 static final int TREEIFY_THRESHOLD = 8; //存放数据的数组,内部都是一个个链表 transient Node<K,V>[] table; //entrySet的引用,会在调用entrySet()时候赋值 transient Set<Map.Entry<K,V>> entrySet; //这个是Map内的元素个数,每put一个元素进来就会+1。当size大于threshold的时候就会扩容 transient int size; //防止并发修改的计数器 transient int modCount; //扩容的阈值 //当数组初始化的时候会被用来记录初始化数组长度,这个时候他的长度就是数组的长度。其他的时候就和数组长度没哈关系了 int threshold; //扩容因子,默认值是 DEFAULT_LOAD_FACTOR = 0.75。用来计算扩容的阈值 final float loadFactor;
HashMap的构造方法
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;
//这里tablesizefor 很重要!!! 它通过获取最近的2^n,指定初始化长度 this.threshold = tableSizeFor(initialCapacity); /** * Constructs an empty <tt>HashMap</tt> with the specified initial * capacity and the default load factor (0.75). * * @param initialCapacity the initial capacity. * @throws IllegalArgumentException if the initial capacity is negative. */ public HashMap(int initialCapacity)
//指定长度,扩容因子0.75 this(initialCapacity, DEFAULT_LOAD_FACTOR); /** * Constructs an empty <tt>HashMap</tt> with the default initial capacity * (16) and the default load factor (0.75). */ public HashMap()
//默认长度16 扩容因子0.75 this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted /** * Constructs a new <tt>HashMap</tt> with the same mappings as the * specified <tt>Map</tt>. The <tt>HashMap</tt> is created with * default load factor (0.75) and an initial capacity sufficient to * hold the mappings in the specified <tt>Map</tt>. * * @param m the map whose mappings are to be placed in this map * @throws NullPointerException if the specified map is null */ public HashMap(Map<? extends K, ? extends V> m) this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false);
static final int tableSizeFor(int cap) int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
当cap正好就是2^n时 不减1会直接扩大一倍
之前说了cap最近的2^n数字,比如 3 离它最近的是4,那么对于4呢?其实离他最近的是8。
如果直接运算,会扩大一倍,所以将4减1,变成3,也就是离3最近的就是4。
n溢出,或者达到最大容量。正常情况下将n+1还原
扩容:
扩容,是hashmap的一个核心方法,这个跟其他容器一样,为了节约空间。所以我们在业务开发中,往往在特定长度需要指定它的长度避免它频繁扩容!!!
final Node<K,V>[] resize() //记住当前的元素数组 Node<K,V>[] oldTab = table; //当前的数组容量,没有初始化就是0 int oldCap = (oldTab == null) ? 0 : oldTab.length; //当前的数组的分配长度 int oldThr = threshold; //newCap是新的数组长度和newThr 是新的数组容量 int newCap, newThr = 0; //原来的数组不是空的 if (oldCap > 0) //原来的数组已经达到最大长度限制 if (oldCap >= MAXIMUM_CAPACITY) //直接将分配长度扩大成最大值,其实就是不能扩容了。 threshold = Integer.MAX_VALUE; return oldTab; //如果不是初始容量,老的数组长度扩大2倍 并且还在最大容量内 //并且老的容量已经超过了默认的初始值16 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) //新的阈值是原来的2倍 newThr = oldThr << 1; //初始化的时候指定了容量,直接使用指定的容量初始化 //这里比较绕,因为它构造里边用的是threshold来记录的初始化长度 //所以后边还会有 newThr == 0 的情况 else if (oldThr > 0) newCap = oldThr; else //采用默认的数据初始化,数组长度是 16,扩容因子是0.75。阈值是12 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); //这里是,初始化的时候指定了容量 //或者现有的容量小于 DEFAULT_INITIAL_CAPACITY 或者 扩容后超出MAXIMUM_CAPACITY 是触发 if (newThr == 0) //新的数组扩容阈值 float ft = (float)newCap * loadFactor; //确定新的阈值,新的容量小于MAXIMUM_CAPACITY 并且临界值 也小于MAXIMUM_CAPACITY 才会用ft newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); //新的阈值 threshold = newThr; @SuppressWarnings("rawtypes","unchecked") //使用新的容量去初始化一个新的Node数组,newCap必须是2^n Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; //处理旧数组里的数组 if (oldTab != null) //遍历原来数组的每个元素 for (int j = 0; j < oldCap; ++j) Node<K,V> e; if ((e = oldTab[j]) != null) oldTab[j] = null; if (e.next == null) //如果只有一个元素,将元素的hash与现在的位置取余确定新的数组下标 newTab[e.hash & (newCap - 1)] = e; else if (e instanceof TreeNode) //对于数结构的处理 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else //这个是将原始的链表拆分开,分成高位和低位。 //低位不用移动,高位放到现有的位置 + 数组的原始长度处 Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do next = e.next; //这个就是判断数据是处于低位还是高位 if ((e.hash & oldCap) == 0) if (loTail == null) loHead = e; else loTail.next = e; loTail = e; else if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; while ((e = next) != null); //将低位元素放在原始的索引处 if (loTail != null) loTail.next = null; newTab[j] = loHead; //将高位元素放在原始的索引 + 原始的数组长度处 if (hiTail != null) hiTail.next = null; newTab[j + oldCap] = hiHead; //返回新的元素 return newTab;
对于新数组容量和扩容阈值;
数组中链表只有一个元素,那么直接存储;
数组中是一个红黑树结构;
数组中还没有到达红黑树结构、但是有多个、拆分链表;
其中,HashMap提供了TreeNode,当数组内链表长度大于8后转换为TreeNode。
其中,默认的扩容因子是0.75,意味着:当添加某个元素后,数组的总的添加元素数大于了 数组长度 * 0.75(默认,也可自己设定),数组长度扩容为两倍。(如开始创建HashMap集合后,数组长度为16,临界值为16 * 0.75 = 12,当加入元素后元素个数超过12,数组长度扩容为32,临界值变为24)
Map只有在添加的时候才会扩容,删除的时候是不会缩容的。
接下来,我们看看针对容器的操作方法:
添加元素:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) Node<K,V>[] tab; Node<K,V> p; int n, i; //如果table 还没初始化,调用resize()初始化一个数组 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //通过(n - 1) & hash 计算元素的下标,如果下标出没有元素,直接初始化一个 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else //下标出已经有元素了,p就是下标处链表的头 Node<K,V> e; K k; //当前元素的hash和下标处表头的hash相等,并且他们的key相等,直接辅助e //这其实是个优化措施,比如你put一个key已经存在的元素 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //如果p已经转化成树结构,树结构处理 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else //以p为起点,循环链表 for (int binCount = 0; ; ++binCount) if ((e = p.next) == null) //如果链表的尾部还没找到,就添加一个元素进去 p.next = newNode(hash, key, value, null); //是否有必要转换成树结构 增强查询效率 if (binCount >= TREEIFY_THRESHOLD - 1) treeifyBin(tab, hash); break; //如果在中间找到了,记下这个节点 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; //当前key对应的节点已经存在 if (e != null) //将原来节点里的值替换掉 V oldValue = e.value; //原来的值是null或者明确指定了onlyIfAbsent 的才更新 if (!onlyIfAbsent || oldValue == null) e.value = value; //模板方法,里边是个空实现 afterNodeAccess(e); return oldValue; //操作次数加1 ++modCount; //元素数量+1,并且与扩容阈值比较 if (++size > threshold) resize(); //模板方法,里边是个空实现 afterNodeInsertion(evict); return null;
总体来说,HashMap添加元素可以分为这几步:
1.初始化
2.查找元素位置,如果当前位置是空,那么直接放,如果不是空就遍历链表看看元素是否存在?存在就更新,不存在则添加
public V put(K key, V value) return putVal(hash(key), key, value, false, true); public V putIfAbsent(K key, V value) return putVal(hash(key), key, value, true, true);
这些都是基于putVal去实现的。
如果通过别的方式添加元素,例如putMapEntries:
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) //获取待添加的map元素个数 int s = m.size(); if (s > 0) if (table == null) //反算一下容量的大小 float ft = ((float)s / loadFactor) + 1.0F; int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); //已经超过了容量的阈值 if (t > threshold) //将容量阈值规整为2^n形式 threshold = tableSizeFor(t); //如果超限了 直接去扩容,确定一个合适的容量 else if (s > threshold) resize(); for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) K key = e.getKey(); V value = e.getValue(); //使用putVal一个一个添加 putVal(hash(key), key, value, false, evict); //快捷方法 public void putAll(Map<? extends K, ? extends V> m) putMapEntries(m, true);
删除元素:
final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) Node<K,V>[] tab; Node<K,V> p; int n, index; //只有table不为空,并且存在数据的时候才处理 if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) Node<K,V> node = null, e; K k; V v; //这里是为了key对应的元素位置,一样是个优化策略。直接看第一个位置是不是要找的元素 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) node = p; else if ((e = p.next) != null) //数结构单独处理 if (p instanceof TreeNode) node = ((TreeNode<K,V>)p).getTreeNode(hash, key); else //循环列表,看能不能找到key对应的位置 do if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) node = e; break; p = e; while ((e = e.next) != null); //通过上边的查找,如果找到了key对应的node节点 //如果启用了value匹配,并且value是匹配的。就准备删除节点 if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) //树节点单独处理 if (node instanceof TreeNode) ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable); else if (node == p) //如果第一个节点就是node,直接替换掉第一个元素 tab[index] = node.next; else //直接替换next指针到node的下一个节点 p.next = node.next; //操作次数增加 ++modCount; //元素数量减少 --size; //模板方法,其实是个空实现 afterNodeRemoval(node); return node; return null;
删除元素的动作也可以分为两步:
1.找到key对应的位置,且hash和key都相等
2.处理结构,数组还是链表?
public V remove(Object key) Node<K,V> e; return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; public boolean remove(Object key, Object value) return removeNode(hash(key), key, value, true, true) != null;
以上都是通过removeNode实现。
清空元素:
public void clear() Node<K,V>[] tab; modCount++; if ((tab = table) != null && size > 0) size = 0; //直接把数组的元素置空,数组的大小不变 for (int i = 0; i < tab.length; ++i) tab[i] = null;
查找元素:
这是比较核心,也比较粗暴的方式
final Node<K,V> getNode(int hash, Object key) Node<K,V>[] tab; Node<K,V> first, e; int n; K k; //table不是空的,并且key对应的下标是有数据存在的 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) //优化策略,首先检查第一个节点不是要找e 的key,如果是直接返回。 if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k)))) return first; //链表不止一个元素 if ((e = first.next) != null) //树结构单独处理 if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); do //循环链表一直找到key if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; while ((e = e.next) != null); return null;
查找元素,就过于粗暴了。当然为了效率,会在链表长度大于8时自动转化为红黑树结构。
public V get(Object key) Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; public boolean containsKey(Object key) return getNode(hash(key), key) != null; //不存在元素的时候,使用一个默认值替换 public V getOrDefault(Object key, V defaultValue) Node<K,V> e; return (e = getNode(hash(key), key)) == null ? defaultValue : e.value;
这些都是通过getNode实现。
修改元素:
public boolean replace(K key, V oldValue, V newValue) Node<K,V> e; V v; //直接获取元素,如果是空的或者和oldValue不想等,就不处理 if ((e = getNode(hash(key), key)) != null && ((v = e.value) == oldValue || (v != null && v.equals(oldValue)))) e.value = newValue; afterNodeAccess(e); return true; return false; //和上边一样,只是少了旧值的比较操作 public V replace(K key, V value) Node<K,V> e; if ((e = getNode(hash(key), key)) != null) V oldValue = e.value; e.value = value; afterNodeAccess(e); return oldValue; return null; public void replaceAll(BiFunction<? super K, ? super V, ? extends V> function) Node<K,V>[] tab; if (function == null) throw new NullPointerException(); if (size > 0 && (tab = table) != null) int mc = modCount; //先循环数组 for (int i = 0; i < tab.length; ++i) //在循环数组内的链表 for (Node<K,V> e = tab[i]; e != null; e = e.next) //使用function替换每个元素 e.value = function.apply(e.key, e.value); //如果发生了并发修改,就报错 if (modCount != mc) throw new ConcurrentModificationException();
遍历容器:
HashIterator 是针对内部key和value的迭代器实现
abstract class HashIterator Node<K,V> next; // next entry to return Node<K,V> current; // current entry int expectedModCount; // for fast-fail int index; // current slot HashIterator() //记录当前被操作数量 expectedModCount = modCount; //table引用 Node<K,V>[] t = table; current = next = null; index = 0; //这个是为找到数组中第一个不是空的链表 if (t != null && size > 0) // advance to first entry do while (index < t.length && (next = t[index++]) == null); public final boolean hasNext() return next != null; final Node<K,V> nextNode() Node<K,V>[] t; Node<K,V> e = next; //并发操作检测 if (modCount != expectedModCount) throw new ConcurrentModificationException(); //如果数组中存在空的就报错 if (e == null) throw new NoSuchElementException(); //获取到下一个不是空的元素,并赋值给current 和 next if ((next = (current = e).next) == null && (t = table) != null) do while (index < t.length && (next = t[index++]) == null); return e; //就是用的removeNode 其实只是加了并发检测 public final void remove() Node<K,V> p = current; if (p == null) throw new IllegalStateException(); if (modCount != expectedModCount) throw new ConcurrentModificationException(); current = null; K key = p.key; removeNode(hash(key), key, null, false, false); expectedModCount = modCount;
KeySet
public Set<K> keySet() Set<K> ks = keySet; if (ks == null) //创建一个KeySet引用 ks = new KeySet(); keySet = ks; return ks; final class KeySet extends AbstractSet<K> public final int size() return size; public final void clear() HashMap.this.clear(); //这个就是for循环语法糖会用的 public final Iterator<K> iterator() return new KeyIterator(); public final boolean contains(Object o) return containsKey(o); public final boolean remove(Object key) return removeNode(hash(key), key, null, false, true) != null; public final Spliterator<K> spliterator() return new KeySpliterator<>(HashMap.this, 0, -1, 0, 0); public final void forEach(Consumer<? super K> action) Node<K,V>[] tab; if (action == null) throw new NullPointerException(); if (size > 0 && (tab = table) != null) int mc = modCount; //先循环数组 for (int i = 0; i < tab.length; ++i) //再循环链表,但是只寻找元素的key for (Node<K,V> e = tab[i]; e != null; e = e.next) action.accept(e.key); if (modCount != mc) throw new ConcurrentModificationException(); //继承了上边的HashIterator,next里只key final class KeyIterator extends HashIterator implements Iterator<K> public final K next() return nextNode().key;
Values
public Collection<V> values() Collection<V> vs = values; if (vs == null) //使用Values,和keySet机会 vs = new Values(); values = vs; return vs; final class Values extends AbstractCollection<V> public final int size() return size; public final void clear() HashMap.this.clear(); //这个就是for循环语法糖会用的 public final Iterator<V> iterator() return new ValueIterator(); public final boolean contains(Object o) return containsValue(o); public final Spliterator<V> spliterator() return new ValueSpliterator<>(HashMap.this, 0, -1, 0, 0); public final void forEach(Consumer<? super V> action) Node<K,V>[] tab; if (action == null) throw new NullPointerException(); if (size > 0 && (tab = table) != null) int mc = modCount; for (int i = 0; i < tab.length; ++i) for (Node<K,V> e = tab[i]; e != null; e = e.next) action.accept(e.value); if (modCount != mc) throw new ConcurrentModificationException(); //继承了上边的HashIterator,next里只value final class ValueIterator extends HashIterator implements Iterator<V> public final V next() return nextNode().value;
快速失败
8版本之后的快速失败:HashMap提供了一个modCount来定义修改的次数,如果常规遍历的情况下,再进行操作时会修改modCount的值,但是不会修改expectedModCount,每次操作时只要它两不相等就会出现ConcurrentModificationException异常。如果使用迭代器Iterator本身的操作例如remove方法操作时,会同时更新expectedModCount,那么就不会抛出ConcurrentModificationException的异常了。
树化
TreeNode是HashMap的内部类,当数组内链表的长度大于8的时候会转换为TreeNode,进一步加强查找效率
首先,为什么HashMap要采用红黑树呢?具体我们可以参考红黑树的数据结构模型。这里不做过多解释,但是要明确一点,红黑树的特性就是从根到叶子节点的长度不会过长,它们是一个相对平衡的状态,哪怕有大量的数据,也能避免在一个根节点下大量遍历查找。从而提高查找效率。
但是通过添加到指定长度以上,会进入treeifyBin方法,这个方法会将Node节点转为TreeNode节点。注意!!!!因为Node有指针的,这里还是上下节点,所以它还没有达到一个红黑树的标准。
我们都知道看一个树形结构,我们要通过它的继承看到它的特性。我们来看看Node和TreeNode的区别:
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> static class Node<K,V> implements Map.Entry<K,V>
它们都基于KV存储实现,但是不一样的是TreeNode又继承了linkedHashMap。
final void treeifyBin(Node<K,V>[] tab, int hash) int n, index; Node<K,V> e; //如果当前列表小于64 就进行扩容就够了 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); else if ((e = tab[index = (n - 1) & hash]) != null) //如果已经大于64了,先将链表转化为树结构,注意此时还不是红黑树 这些树的元素通过next指针相连 TreeNode<K,V> hd = null, tl = null; do //先生成TreeNode节点 TreeNode<K,V> p = replacementTreeNode(e, null); if (tl == null) //先将head 设置为第一个TreeNode hd = p; else //新插入的TreeNode 的parent 就是上一个Node 如果前面没有那就是header p.prev = tl; tl.next = p; //尾设置为最新插入的节点 tl = p; while ((e = e.next) != null); if ((tab[index] = hd) != null) //树化 hd.treeify(tab);
通过treeifyBin我们可以发现,这里开始处理TreeNode了,但此时它应该称为树,而不是红黑树,因为它们的节点是相连的
final void treeify(Node<K,V>[] tab) TreeNode<K,V> root = null; //遍历树节点 for (TreeNode<K,V> x = this, next; x != null; x = next) //处理第一个树节点 next = (TreeNode<K,V>)x.next; x.left = x.right = null; if (root == null) //如果当前没有根节点,那么它就当根节点 它是一个black节点 x.parent = null; x.red = false; root = x; else K k = x.key; int h = x.hash; Class<?> kc = null; for (TreeNode<K,V> p = root;;) //如果已经有根节点,那么就要遍历处理向左或者向右偏移 int dir, ph; K pk = p.key; //如果hash大于插入节点的hash 那么就要去左树继续查找 if ((ph = p.hash) > h) dir = -1; //右边查找 同理 else if (ph < h) dir = 1; //如果相同,就单独处理 else if ((kc == null && (kc = comparableClassFor(k)) == null) || (dir = compareComparables(kc, k, pk)) == 0) dir = tieBreakOrder(k, pk); TreeNode<K,V> xp = p; //根据dir 就知道到底在左还是在右 //如果当前为空了,那么就应该将它插入了 if ((p = (dir <= 0) ? p.left : p.right) == null) //修改指针 x.parent = xp; if (dir <= 0) xp.left = x; else xp.right = x; //旋转处理 root = balanceInsertion(root, x); break; moveRootToFront(tab, root);
那么在数据找到位置并插入后,就要做红黑树的处理balanceInsertion
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root, TreeNode<K,V> x) x.red = true; for (TreeNode<K,V> xp, xpp, xppl, xppr;;) if ((xp = x.parent) == null) x.red = false; return x; else if (!xp.red || (xpp = xp.parent) == null) return root; if (xp == (xppl = xpp.left)) if ((xppr = xpp.right) != null && xppr.red) xppr.red = false; xp.red = false; xpp.red = true; x = xpp; else if (x == xp.right) root = rotateLeft(root, x = xp); xpp = (xp = x.parent) == null ? null : xp.parent; if (xp != null) xp.red = false; if (xpp != null) xpp.red = true; root = rotateRight(root, xpp); else if (xppl != null && xppl.red) xppl.red = false; xp.red = false; xpp.red = true; x = xpp; else if (x == xp.left) root = rotateRight(root, x = xp); xpp = (xp = x.parent) == null ? null : xp.parent; if (xp != null) xp.red = false; if (xpp != null) xpp.red = true; root = rotateLeft(root, xpp);
这里是将节点旋转处理,做左旋转或右旋转,这里对这个不做过多解释,有兴趣可以根据源码看看如何转换的,其中在插入数据时如果检查到是TreeNode,就根据putTreeVal处理,这里的方法类似。
其中如果是treeNode进行查找 也是根据树形查找方式,这里如果对数据结构比较熟悉的朋友应该已经知道了
final TreeNode<K,V> getTreeNode(int h, Object k) return ((parent != null) ? root() : this).find(h, k, null);
-
ConcurrentHashMap
ConcurrentHashMap是juc下的一个线程安全容器,主要是为了解决HashMap线程不安全的场景。
在jdk7中,ConcurrentHashMap保证线程安全的方式就是Segment,分段锁,默认Segment是16个,分段加锁保证读写效率。可以在初始化时修改
每个Segment中,我们可以看作每个Segment里面就是一个HashMap的结构,每个Segment都持有了独立的ReentrantLock,关于ReentrantLock可以参考aqs的原理。
所以在操作每个Segment时,互相是不干扰的。
但是在jdk8里,将Segment换成了Node,每个Node有自己的锁,通过CAS原理。
简单了解一下7中的原理
static final class Segment<K,V> extends ReentrantLock implements Serializable private static final long serialVersionUID = 2249069246763182397L; static final int MAX_SCAN_RETRIES = Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1; transient volatile HashEntry<K,V>[] table; transient int count; transient int modCount; transient int threshold; final float loadFactor; final Segment<K,V>[] segments;
HashEntry(哈希数组),threshold(扩容阈值),loadFactor(负载因子)表示segment是一个完整的HashMap。
public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel)
初始容量:初始容量表示所有的segment数组中,一共含有多少个hashentry。若initialCapacity不为2的幂,会取一个大于initialCapacity的2的幂。
负载因子:默认0.75
并发级别:可以同时允许多少个线程并发。concurrencyLevel为多少,就有多少个segment,当然也会取一个大于等于这个值的2的幂。
而在8中,其实采用的是Node数组+链表+红黑树,例如put方法就是通过CAS原理 比较替换的方式添加。
那8下的ConcurrentHashMap如何保证线程安全的呢?其实是使用Unsafe操作+synchronized关键字;
synchronized在操作的位置进行加锁、比如我们向某个链表插入数据,那就会在Node上先同步、然后通过CAS插入。在8版本下其实也有分段的思想、但是在7中其实我们指定了桶的数量、而在8中认为是每个Node的每个位置都有一把锁。
如果我们这时去put 一个key-value
根据key计算数组下标、如果没有元素,则CAS添加;
如果有元素 那么先synchronized锁定;
加锁成功,判断元素类型,如果是链表那么就添加到链表、如果是红黑树节点那么添加到TreeNode;
添加完成,需要看是否需要转化成树结构;
-
HashTable
hashTable跟HashMap最大的区别,就是HashTable是保证线程安全的,它的所有操作都进行了加锁。
HashMap底层是一个Entry数组,当发生hash冲突的时候,hashmap是采用链表的方式来解决的,在对应的数组位置存放链表的头结点。对链表而言,新加入的节点会从头结点加入。在hashmap做put操作的时候可能会造成数据丢失。现在假如A线程和B线程同时对同一个数组位置调用addEntry,两个线程会同时得到现在的头结点,然后A写入新的头结点之后,B也写入新的头结点,那B的写入操作就会覆盖A的写入操作造成A的写入操作丢失。
-
TreeMap
对于treeMap,很多人很陌生、它也是基于Map接口实现的。
TreeMap是一个基于key有序的key value的散列。
map根据创建的key的排序方式、或者重写Comparator的排序方式进行排序;底层还是基于红黑树的结构实现的。
从它的继承结构可以清晰的看出来,它又实现了sorted排序方式,所以它是一个有序的。
本文来自博客园,作者:青柠_fisher,转载请注明原文链接:https://www.cnblogs.com/oldEleven/p/17284659.html
打怪升级jvm关于jvm内存模型及GC调优
JVM调优,其实就是调整SWT和FGC的过程
-
JVM内存模型
通过一张基础的图了解最简单的JVM模型:
其实在jvm模型中,主要包含了我们常见的堆栈方法区等待--每个版本不同可能解释有所不同,这里默认以8版本为例:
首先给出官方文档的解释:
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5.4
2.5. Run-Time Data Areas
The Java Virtual Machine defines various run-time data areas that are used during execution of a program. Some of these data areas are created on Java Virtual Machine start-up and are destroyed only when the Java Virtual Machine exits. Other data areas are per thread. Per-thread data areas are created when a thread is created and destroyed when the thread exits.
The Java Virtual Machine can support many threads of execution at once (JLS §17). Each Java Virtual Machine thread has its own
pc
(program counter) register. At any point, each Java Virtual Machine thread is executing the code of a single method, namely the current method (§2.6) for that thread. If that method is notnative
, thepc
register contains the address of the Java Virtual Machine instruction currently being executed. If the method currently being executed by the thread isnative
, the value of the Java Virtual Machine\'spc
register is undefined. The Java Virtual Machine\'spc
register is wide enough to hold areturnAddress
or a native pointer on the specific platform.Each Java Virtual Machine thread has a private Java Virtual Machine stack, created at the same time as the thread. A Java Virtual Machine stack stores frames (§2.6). A Java Virtual Machine stack is analogous to the stack of a conventional language such as C: it holds local variables and partial results, and plays a part in method invocation and return. Because the Java Virtual Machine stack is never manipulated directly except to push and pop frames, frames may be heap allocated. The memory for a Java Virtual Machine stack does not need to be contiguous.
In the First Edition of The Java® Virtual Machine Specification, the Java Virtual Machine stack was known as the Java stack.
This specification permits Java Virtual Machine stacks either to be of a fixed size or to dynamically expand and contract as required by the computation. If the Java Virtual Machine stacks are of a fixed size, the size of each Java Virtual Machine stack may be chosen independently when that stack is created.
A Java Virtual Machine implementation may provide the programmer or the user control over the initial size of Java Virtual Machine stacks, as well as, in the case of dynamically expanding or contracting Java Virtual Machine stacks, control over the maximum and minimum sizes.
The following exceptional conditions are associated with Java Virtual Machine stacks:
If the computation in a thread requires a larger Java Virtual Machine stack than is permitted, the Java Virtual Machine throws a
StackOverflowError
.If Java Virtual Machine stacks can be dynamically expanded, and expansion is attempted but insufficient memory can be made available to effect the expansion, or if insufficient memory can be made available to create the initial Java Virtual Machine stack for a new thread, the Java Virtual Machine throws an
OutOfMemoryError
.The Java Virtual Machine has a heap that is shared among all Java Virtual Machine threads. The heap is the run-time data area from which memory for all class instances and arrays is allocated.
The heap is created on virtual machine start-up. Heap storage for objects is reclaimed by an automatic storage management system (known as a garbage collector); objects are never explicitly deallocated. The Java Virtual Machine assumes no particular type of automatic storage management system, and the storage management technique may be chosen according to the implementor\'s system requirements. The heap may be of a fixed size or may be expanded as required by the computation and may be contracted if a larger heap becomes unnecessary. The memory for the heap does not need to be contiguous.
A Java Virtual Machine implementation may provide the programmer or the user control over the initial size of the heap, as well as, if the heap can be dynamically expanded or contracted, control over the maximum and minimum heap size.
The following exceptional condition is associated with the heap:
The Java Virtual Machine has a method area that is shared among all Java Virtual Machine threads. The method area is analogous to the storage area for compiled code of a conventional language or analogous to the "text" segment in an operating system process. It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods (§2.9) used in class and instance initialization and interface initialization.
The method area is created on virtual machine start-up. Although the method area is logically part of the heap, simple implementations may choose not to either garbage collect or compact it. This specification does not mandate the location of the method area or the policies used to manage compiled code. The method area may be of a fixed size or may be expanded as required by the computation and may be contracted if a larger method area becomes unnecessary. The memory for the method area does not need to be contiguous.
A Java Virtual Machine implementation may provide the programmer or the user control over the initial size of the method area, as well as, in the case of a varying-size method area, control over the maximum and minimum method area size.
The following exceptional condition is associated with the method area:
A run-time constant pool is a per-class or per-interface run-time representation of the
constant_pool
table in aclass
file (§4.4). It contains several kinds of constants, ranging from numeric literals known at compile-time to method and field references that must be resolved at run-time. The run-time constant pool serves a function similar to that of a symbol table for a conventional programming language, although it contains a wider range of data than a typical symbol table.Each run-time constant pool is allocated from the Java Virtual Machine\'s method area (§2.5.4). The run-time constant pool for a class or interface is constructed when the class or interface is created (§5.3) by the Java Virtual Machine.
The following exceptional condition is associated with the construction of the run-time constant pool for a class or interface:
See §5 (Loading, Linking, and Initializing) for information about the construction of the run-time constant pool.
2.5.6. Native Method Stacks
An implementation of the Java Virtual Machine may use conventional stacks, colloquially called "C stacks," to support
native
methods (methods written in a language other than the Java programming language). Native method stacks may also be used by the implementation of an interpreter for the Java Virtual Machine\'s instruction set in a language such as C. Java Virtual Machine implementations that cannot loadnative
methods and that do not themselves rely on conventional stacks need not supply native method stacks. If supplied, native method stacks are typically allocated per thread when each thread is created.This specification permits native method stacks either to be of a fixed size or to dynamically expand and contract as required by the computation. If the native method stacks are of a fixed size, the size of each native method stack may be chosen independently when that stack is created.
A Java Virtual Machine implementation may provide the programmer or the user control over the initial size of the native method stacks, as well as, in the case of varying-size native method stacks, control over the maximum and minimum method stack sizes.
The following exceptional conditions are associated with native method stacks:
- If the computation in a thread requires a larger native method stack than is permitted, the Java Virtual Machine throws a
StackOverflowError
.
- If native method stacks can be dynamically expanded and native method stack expansion is attempted but insufficient memory can be made available, or if insufficient memory can be made available to create the initial native method stack for a new thread, the Java Virtual Machine throws an
OutOfMemoryError
.
针对oracle的官方文档,给我们描述了jvm虚拟机的几个主要模块。那么从普通开发者的角度,其实这几大块分别干了这些事:
-
- Heap堆:
Java虚拟机具有在所有Java虚拟机线程之间共享的堆。堆是运行时数据区,从中为所有类实例和数组分配内存。堆可以是固定大小的,或者可以根据计算的需要进行扩展,并且如果不需要更大的堆,则可以收缩。堆的内存不需要是连续的。 Java虚拟机实现可以为程序员或用户提供对堆的初始大小的控制,以及如果堆可以动态扩展或收缩,则可以提供对最大和最小堆大小的控制。如果计算所需的堆数超过了自动存储管理系统所能提供的堆数,Java虚拟机将抛出OutOfMemoryError。
那么可以看出,堆的主要作用就是分配空间,属于运行时数据区,会将我们运行时的内存分配。
同时,堆内存又分为新生代和老年代,以Young和Old区分,其中新生代主要存放回收年龄较短或者一些新new的对象,而老年代则是存放一些无法被gc的对象(考虑一下哪些对象会被放入老年代?)
堆内存,其实又被分为了三个区域:
Young:新生代;Old:老年代;Mate:元空间(永久代)
其中,新生代又分为这几段:
Eden:伊甸区;
s0、s1:survivor,用于YGC中复制移动;
默认,Eden:s0:s1是8:1:1的关系,而老年代和年轻代的比例默认是2:1
那么,什么时候会放入新生代什么时候会放入老年代呢?
默认,新创建的对象第一次会存放在新生代Eden中,我们认为新生代的对象百分之80都可能被回收掉,那么第一次YGC就会把Eden的对象先复制到s0,这是内存的复制,速度很快;
下一次YGC,又会把Eden和不为空的s0做YGC,因为我们认为大多数对象都要被回收,再把Eden和s0全部清空,那么没有回收的对象就位于s1;
再下一次.... 就是s0和s1来回倒。
但是,如果对象很大无法被放入新生代,或者它已经超过动态survivo的大小50%以上,我们就认为它不适合在新生代了,就会直接放去老年代。
同时,jvm的Object有对象头,对象头包含了比如说对象线程,锁,和一些gc年龄的属性,那么我们认为默认它的年龄到达15,它就是一个不可被回收的对象,那么就放入老年代;大对象也会放入老年代;还有如果Eden满了出发YGC,那么存活对象大小s0没办法承受也会将部分多余的对象放入老年代,我们认为老年代的对象很难被回收,那么什么对象可以出现在老年代呢?例如:spring生命周期的bean,常量(这里string常量池其实有些许变化),定义线程池、连接池这些都应该属于不可被回收。
-
- Stock栈(也叫虚拟机栈,线程栈)
每个Java虚拟机线程都有一个与线程同时创建的专用Java虚拟机堆栈。该规范允许Java虚拟机堆栈具有固定的大小,或者根据计算的需要动态扩展和收缩。如果Java虚拟机堆栈具有固定大小,则在创建每个Java虚拟机栈时,可以独立地选择该栈的大小。如果线程中的计算需要比允许的更大的Java虚拟机堆栈,则Java虚拟机会抛出StackOverflowError。如果Java虚拟机堆栈可以动态扩展,并且尝试进行扩展,但没有足够的内存可用于实现扩展,或者如果没有足够的存储器可用于创建新线程的初始Java虚拟机栈,则Java虚拟机将抛出OutOfMemoryError。
栈,顾名思义,是一种FILO的结构,那么在我们方法调用时,就会在栈内存中存储,包括一些对象的引用,栈中又有一个概念叫栈桢,什么是栈帧呢?
简单举例子就是说,比如我们有一个A方法调用B方法,B再调用C方法,那么这个栈帧可以这么表示:
并且,栈存储会随着线程的创建而创建,会随着线程的销毁而释放,不存在gc。这里其实有一个概念,比如说栈内存的OOM,会是什么样子?
如果一个递归,无法跳出递归或者递归数量太大,栈内存设置太小,是可能会抛出OOM的。
局部变量表:对应的就是方法参数和一些局部变量,因为这些都是线程私有的,所以不需要额外gc,随着线程结束被释放。
操作数栈:栈内的一些计算操作。
动态链接:一些引用?
方法返回地址:调用当前方法寄存器的值。
-
- Native Method Stock本地方法栈
与stock类似,只不过这里存储的是基于一些native之类的本地方法。
-
- Method Area 方法区/元空间
首先,明确一个概念,方法区/元空间,又可以叫做no-heap,它是用于与堆内存进行分开的概念!这个概念在jdk7叫做方法区,在jdk8叫做元空间,而且元空间不需要指定默认的大小了,而是会根据物理内存进行计算,当然如果物理内存不够了也会抛出OOM。并且,元空间是代替了7的老年代,其本质也是属于堆的一部分。
那么,为什么从7->8会有这样的变化呢?
1.永久代是有固定空间的,如果永久代的空间太小,-XX:MaxPermSize太小就会导致永久代OOM。
2.元空间使用的情况根据物理存储,最大没有限制,当物理内存达不到要求后同样会出发OOM。windows下,-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize的值是-1。
3.MetaspaceSize是初始化指定的大小,当达到了这个大小后,会触发FGC,同时会根据FGC的回收情况适当调整。所以线上如果频繁FGC,可能跟这个值有关,可以适当增大。
-
- STW
STW全名叫stop the world,它代表的意思是说在jvm触发GC时,会停止当前所有的用户线程,然后在gc完成后释放,那么在gc的时间段内就会发生所有的动作暂停无响应的情况。当然后面会有针对G1,ZGC以及一些回收算法可以并行的模式。
其中,Class对象 -- 永久代/元数据;
字符串常量 -- 1.7永久代;1.8在堆heap里;
在1.8之后,元数据不存在于堆,而是根据操作系统的内存进行管理的;
-
什么是垃圾
垃圾,顾名思义就是要被回收的对象,或者说要被回收的一组对象。
首先我们需要有个概念,java本身针对内存指针甚至内存空间的方式,都是基于unsafe或者其他的方式,我们在写java代码中不需要手动释放内存,这与c有很大的不同。
那么为什么java帮助我们去这么做呢?无非这么几点
1.忘记回收内存,那么这一块内存空间就会被占用无法释放。
2.多次回收,那么会不会将新的可用的数据回收掉?
3.更加简化开发。
那既然GC就是回收不可用的垃圾,是有jvm帮我们去完成的,那么jvm到底是如何确定什么是垃圾的呢?
主要有两种办法:
1.计数器,jvm会在对象头记录计数器,计数器代表引用计数器,那么如果这个计数器为0了,它没有被任何对象引用那么它就是一个可以回收的垃圾了。
2.GCROOT,根可达算法:试想一下如果有三个对象ABC,A持有B,B持有C,C持有A,但是它们再没有其他的对象引用了,那么它们的引用计数器不为0,但是它们其实算一堆垃圾,这样的话我们的根可达就派上用场了。比如我们根据Object,一直向下去找,那么找得到的对象就是可用的对象,那么其他的对象都可以称为不可用对象。什么概念呢?比如我们的局部变量 和我们的连接池 线程池 包括常量池 JNI指针持有的对象那这些一定是可用变量甚至加载的Clazz,一定不会被回收。
什么是对象头?
object header | Mark word(64bit) | klass pointer(64bit) |
normal object 普通对象,无锁。 | unused:25|identity_hashcode:31|unused:1|age:4|biased_lock:1|lock:2 | oop to metadata object 指向对象的元定义,可能会被指针指针压缩 |
biased object 带有偏向锁的对象 | thread:54 |epoch:2 |unused:1|age:4|biased_lock:1|lock:2 | oop to metadata object 指向对象的元定义,可能会被指针指针压缩 |
带有轻量锁的对象 | prt_to_lock_record:62 |lock:2 | oop to metadata object 指向对象的元定义,可能会被指针指针压缩 |
带有重量锁的对象 | prt_to_heavyweigth_monitor:62 |lock:2 | oop to metadata object 指向对象的元定义,可能会被指针指针压缩 |
GC | |lock:2 | oop to metadata object 指向对象的元定义,可能会被指针指针压缩 |
-
常见的垃圾回收算法
- 标记清除
首先对垃圾进行标记,然后进行回收,但是这种方式会导致内存间断,产生大量的内存碎片;这时当我们要分配一个大的对象时,可能会经历频繁的GC(目前大对象也可以直接扔在老年代中)
-
- copy复制
复制算法,相当于把可用的对象复制在另一个内存块中,然后直接将当前的内存清除;这样不会有内存碎片,但是缺点是内存占用会很大,最少需要将内存划分成两块进行复制移动,而且存活对象过多会导致效率低下
-
- 标记压缩
这时复制的升级版,其实它会将不可用对象清除后将可用对象向一端移动,这样的好处是不会有内存碎片并且不需要划分内存空间、但是效率会比较低
-
- 分代收集
这是最常用的算法,会根据内存空间进行划分,并且针对不同的内存选择不同的算法:例如新生代可以选用复制算法,默认新生代的对象很多都需要被回收;而老年代采用标记压缩或复制,腾出大片内存。
-
常见的垃圾回收器
- Serial Young:串行回收,会触发STW。
- Paraller Scavenge: ps,并行回收。
- ParNew配合CMS: 并行回收年轻代。
- Serial Old:串行回收,老年代。
- ParOld:并行回收,老年代。
- CMS:ConcurrentMarkSweep:并发回收,老年代。GC和用户进程同时进行,降低STW。
- G1:10ms 不区分老年代新生代
- ZGC:1ms 不区分老年代新生代
-
如何判断当前参数
https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
-XX:+PrintCommandLineFlags:当前jvm参数
-XX:+PrintFlgsFinal :最终参数值
-XX:+PrintFlagsInitial:默认参数值
1.8版本默认使用的应该是Paraller GC 并行回收 : 默认使用 Parallel Scavenge(新生代)+ Serial Old(老年代)。
这里整理一个简单的表格,对这几种GC做简单的对比:
GC | 特性 | 描述 |
Paraller | 吞吐量 | 多线程STW |
G1(Garbage first) | 均衡 | 多线程SWT 并发 分代回收 |
ZGC(Z Garbage) | 延迟 | 所有并发 |
Shenandoah(jdk 12后) | 延迟 | 所有并发 |
Serial | 内存大小 启动时间 | 单线程STW |
Parallel GC是JDK 8以及更早版本的默认回收期。它专注于吞吐量,尽快完成工作,而很少考虑延迟(暂停)。
Parallel GC会在STW(全局暂停)期间,以更紧凑的方式,将正在使用中的内存移动(复制)到堆中的其他位置,从而制造出大片的空闲内存区域。当内存分配请求无法满足时就会发生STW暂停,然后JVM完全停止应用程序运行,投入尽可能多的处理器线程,让垃圾回收算法执行内存压缩工作,然后分配请求的内存,最后恢复应用程序执行。
G1 GC是JDK 9以后的默认回收期。G1试图平衡吞吐量和延迟。一方面,在STW暂停期间,依然会利用分代继续执行内存回收工作,从而最大化效率,这一点和Parallel GC相同;但是,它还会尽可能避免在暂停期间执行需要较长时间的操作。G1的长时间操作会与应用程序并行进行,即通过多线程方式,在应用程序运行时执行。这样可以大幅度减少暂停,代价是整体的吞吐量会降低一点。
所以,GC在8-9版本其实是一个分水岭,从9版本后默认使用G1,并且优化了G1处理的时间,包括G1处理大对象及老年代的时间。
那么,哪些场景下适用哪些垃圾回收器呢?
Serial:适用于单线程场景,简单的client客户端,内存小,没有过多的对象,单线程回收不需要线程切换的开销。
ParNew/Paraller :多CPU的服务器,可以采用多线程的方式回收,但是Paraller 追求的是短时间内尽量完成任务,那么就会有SWT时间,不适合交互型场景;ParNew降低了SWT时间,更适合交互场景。
Parallel Scavenge提供的参数
-XX:GCTimeRadio
直接设置吞吐量大小,GC时间占总时间比率.相当于是吞吐量的倒数.
-XX:MaxGCPauseMillis
设置最大GC停顿时间.
Parallel Scavenge会根据这个值的大小确定新生代的大小.如果这个值越小,新生代就会越小,从而收集器就能以较短的时间进行一次回收;但新生代变小后,回收的频率就会提高,吞吐量也降下来了,因此要合理控制这个值.
-XX:+UseAdaptiveSizePolicy
通过命令就能开启GC 自适应的调节策略(区别于ParNew).我们只要设置最大堆(-Xmx)和MaxGCPauseMillis或GCTimeRadio,收集器会自动调整新生代的大小、Eden和Survior的比例、对象进入老年代的年龄,以最大程度上接近我们设置的MaxGCPauseMillis 或GCTimeRadio.
-
聊聊G1
首先一点,不论是新生代还是老年代,G1 ZGC等垃圾回收器是不区分内存类型的。
通过 -XX:+UseG1GC 可以指定使用G1垃圾回收器。
G1首先具备压缩功能、避免碎片化内存问题、而且G1的SWT暂停时间可控、多线程GC、面向服务端应用比较友好、而且可以预测停顿的时间。
首先,G1会将所有堆内存划分成很多块大小相等的Region。每次要触发GC时,首先估算每个Region中可回收垃圾的数量、每次先从可回收最大的量开始回收,因此它的效率性能是很高的。
这样,其实在G1里,不再区分老年代新生代了,整个堆内存都是Region。但是衍生出了一个Humongous,它是特殊的Old,专门存放大型的对象。
这样的划分方式意味着不需要一个连续的内存空间管理对象.G1将空间分为多个区域,优先回收垃圾最多的区域.
G1采用的是Mark-Copy ,有非常好的空间整合能力,不会产生大量的空间碎片
G1的一大优势在于可预测的停顿时间,能够尽可能快地在指定时间内完成垃圾回收任务,通过jstat命令可以查看垃圾回收情况,在YGC时S0/S1并不会交换.
那么,如果一个对象,它自身和它持有引用的对象没有分配在一个Region中,我们是否需要遍历所有的Region才能进行一次GCRoot?每个Region上都有一个RememberSet,用于记录当前区域引用对象所在的区域。
G1的GC模式
1.YoungGC年轻代收集
在分配一般对象(非巨型对象)时,当所有eden region使用达到最大阀值并且无法申请足够内存时,会触发一次YoungGC。每次younggc会回收所有Eden以及Survivor区,并且将存活对象复制到Old区以及另一部分的Survivor区。
YoungGC的回收过程如下:
根扫描,跟CMS类似,Stop the world,扫描GC Roots对象。
处理Dirty card,更新RSet.
扫描RSet,扫描RSet中所有old区对扫描到的young区或者survivor去的引用。
拷贝扫描出的存活的对象到survivor2/old区
处理引用队列,软引用,弱引用,虚引用
2. mixed gc
当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即mixed gc,该算法并不是一个old gc,除了回收整个young region,还会回收一部分的old region,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些old region进行收集,从而可以对垃圾回收的耗时时间进行控制。
G1没有fullGC概念,需要fullGC时,调用serialOldGC进行全堆扫描(包括eden、survivor、o、perm)。
何时使用G1
G1的第一个重要特点是为用户的应用程序的提供一个低GC延时和大内存GC的解决方案。这意味着堆大小6GB或更大,稳定和可预测的暂停时间将低于0.5秒。
如果应用程序使用CMS或ParallelOld垃圾回收器具有一个或多个以下特征,将有利于切换到G1:
Full GC持续时间太长或太频繁
对象分配率或年轻代升级老年代很频繁
不期望的很长的垃圾收集时间或压缩暂停(超过0.5至1秒)
注意:如果你正在使用CMS或ParallelOld收集器,并且你的应用程序没有遇到长时间的垃圾收集暂停,则保持与您的当前收集器是很好的,升级JDK并不必要更新收集器为G1。
-
关于jvm调优
关于jvm调优,我相信很多人甚至不会接触,因为毕竟有多少开发能直接操作线上服务器环境呢?可能也就是公司大牛级别的人了。
jvm本身东西很多,但是更多的说到jvm调优,我们主要是针对full GC 就是FGC的优化,至于YGC 是正常的,但是我们希望在应用服务中,更多的对象应该在YGC被回收,而不是无法回收全部放入FGC,因为FGC里的对象都是长期存活的,对应的FGC的时间也会更长!!同时还有一些基于jvm的参数,例如新生代中eden、s0、s1的大小,这些都会直接影响到对象是否会被直接扔在老年代中。当然,如果线上程序很稳定,jvm监控FGC的频率 时间都很正常,不建议修改jvm的参数!而且升级jdk版本也无需修改GC回收器!!
首先,我们要知道哪些会导致FGC
1.System.gc()方法的调用
此方法的调用是建议JVM进行Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,从而增加Full GC的频率,也即增加了间歇性停顿的次数。强烈影响系建议能不使用此方法就别使用,让虚拟机自己去管理它的内存,可通过通过-XX:+ DisableExplicitGC来禁止RMI(Java远程方法调用)调用System.gc。
2.老年代空间不足
在Survivor区域的对象满足晋升到老年代的条件时,晋升进入老年代的对象大小大于老年代的可用内存,这个时候会触发Full GC。,当执行Full GC后空间仍然不足,则抛出错误:java.lang.OutOfMemoryError: Java heap space 。为避免以上两种状况引起的FullGC,调优时应尽量做到让对象在Minor GC阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。
3.Metaspace区内存达到阈值
4.统计得到的Minor GC晋升到旧生代的平均大小大于老年代的剩余空间 Survivor区域对象晋升到老年代有两种情况:
(1)一种是给每个对象定义一个对象计数器,如果对象在Eden区域出生,并且经过了第一次GC,那么就将他的年龄设置为1,在Survivor区域的对象每熬过一次GC,年龄计数器加一,等到到达默认值15时,就会被移动到老年代中,默认值可以通过-XX:MaxTenuringThreshold来设置。
(2)另外一种情况是如果JVM发现Survivor区域中的相同年龄的对象占到所有对象的一半以上时,就会将大于这个年龄的对象移动到老年代,在这批对象在统计后发现可以晋升到老年代,但是发现老年代没有足够的空间来放置这些对象,这就会引起Full GC。 5.堆中产生大对象超过阈值这个参数可以通过-XX:PretenureSizeThreshold进行设定,大对象或者长期存活的对象进入老年代,典型的大对象就是很长的字符串或者数组,它们在被创建后会直接进入老年代,虽然可能新生代中的Eden区域可以放置这个对象,在要放置的时候JVM如果发现老年代的空间不足时,会触发GC。
6.老年代连续空间不足
JVM如果判断老年代没有做足够的连续空间来放置大对象,那么就会引起Full GC,例如老年代可用空间大小为200K,但不是连续的,连续内存只要100K,而晋升到老年代的对象大小为120K,由于120>100的连续空间,所以就会触发Full GC。
那如何排查服务gc频率呢?
直接上arthas:
- Github:https://github.com/alibaba/arthas
- 文档:https://arthas.aliyun.com/doc/
https://arthas.aliyun.com/doc/vmoption.html
通过dashboard 以及命令可以排查gc的问题:
- 使用vmoption命令动态打开GC日志
$ vmoption PrintGC true
$ vmoption PrintGC true Successfully updated the vm option. NAME BEFORE-VALUE AFTER-VALUE ------------------------------------ PrintGC false true
$ vmoption PrintGCDetails true
$ vmoption PrintGCDetails true Successfully updated the vm option. NAME BEFORE-VALUE AFTER-VALUE ------------------------------------------- PrintGCDetails false true
-
使用vmtool强制GC
$ vmtool --action forceGc
[GC (JvmtiEnv ForceGarbageCollection) [PSYoungGen: 2184K->352K(76288K)] 19298K->17474K(166912K), 0.0011562 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[Full GC (JvmtiEnv ForceGarbageCollection) [PSYoungGen: 352K->0K(76288K)] [ParOldGen: 17122K->16100K(90112K)] 17474K->16100K(166400K), [Metaspace: 20688K->20688K(1069056K)], 0.0232947 secs] [Times: user=0.14 sys=0.01, real=0.03 secs]
- 其他gc参数
$ vmoption PrintGCID true 打印GC ID
$ vmoption PrintGCID true Successfully updated the vm option. NAME BEFORE-VALUE AFTER-VALUE -------------------------------------- PrintGCID false true
$ vmoption PrintGCDateStamps true 打印GC时间戳
$ vmoption PrintGCDateStamps true Successfully updated the vm option. NAME BEFORE-VALUE AFTER-VALUE ---------------------------------------------- PrintGCDateStamps false true
$ vmoption PrintGCTimeStamps true 打印GC启动时间
$ vmoption PrintGCTimeStamps true Successfully updated the vm option. NAME BEFORE-VALUE AFTER-VALUE ---------------------------------------------- PrintGCTimeStamps false true
- heapdump
打开HeapDumpBeforeFullGC开关,可以在GC前生成heapdump文件;打开HeapDumpAfterFullGC开关,可以在GC结束后生成heapdump文件
$ vmoption HeapDumpBeforeFullGC true Successfully updated the vm option. NAME BEFORE-VALUE AFTER-VALUE ------------------------------------------------- HeapDumpBeforeFullGC false true $ vmtool --action forceGc
再使用vmtool --action forceGc强制GC,则可以在GC日志中发现heapdump信息,并且在应用目录下会生成heapdump hprof
打开PrintClassHistogramBeforeFullGC开关,可以在GC前打印类直方图;打开PrintClassHistogramAfterFullGC开关,可以在GC结束后打印类直方图
$ vmoption PrintClassHistogramBeforeFullGC true Successfully updated the vm option. NAME BEFORE-VALUE AFTER-VALUE ------------------------------------------------------------ PrintClassHistogramBeforeFullGC false true $ vmtool --action forceGc
再使用vmtool --action forceGc强制GC,在GC日志中会打印类直方图,可以直观知道每个类的instances数量,占用内存大小:
#13: [Class Histogram (before full gc): num #instances #bytes class name ---------------------------------------------- 1: 24519 5783400 [C 2: 5648 5102712 [B 3: 3685 888128 [Ljava.lang.Object; 4: 3255 619560 [I 5: 24263 582312 java.lang.String 6: 4227 475320 java.lang.Class 7: 1288 402112 [Ljava.util.HashMap$Node; 8: 75 296160 [Ljava.nio.channels.SelectionKey; 9: 6759 216288 java.util.HashMap$Node 10: 2069 182072 java.lang.reflect.Method 11: 3326 133040 java.util.LinkedHashMap$Entry
具体使用,参考arthas的使用文档。
-
常用JVM参数
堆设置: -Xmx3500m 设置JVM最大可用内存为3550M -Xms3500m 设置JVM堆内存为3550m。此值可以设置与-Xmx相同,以避免每次垃圾回收完成后JVM重新分配内存 -Xmn2g 设置年轻代大小为2G -Xss128k 设置每个线程的堆栈大小 ‐XX:MetaspaceSize=256M 设置元空间大小 ‐XX:MaxMetaspaceSize=256M 设置元空间最大值 -XX:NewRatio=4 设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代) -XX:SurvivorRatio=4 设置年轻代中Eden区与Survivor区的大小比值。设置为4,则两个Survivor区与一个Eden区的比值为2:4 -XX:MaxPermSize=16m 设置持久代大小为16m -XX:MaxTenuringThreshold=0 设置垃圾最大年龄,如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代 垃圾收集器: -XX:+UseParallelGC 选择垃圾收集器为并行收集器 -XX:ParallelGCThreads=20 配置并行收集器的线程数 -XX:+UseParallelOldGC 配置年老代垃圾收集方式为并行收集 -XX:MaxGCPauseMillis=100 设置每次年轻代垃圾回收的最长时间,如果无法满足此时间,JVM会自动调整年轻代大小,以满足此值 -XX:+UseConcMarkSweepGC设置年老代为CMS并发收集 -XX:+UseParNewGC 设置年轻代为并行收集。可与CMS收集同时使用 日志打印: -XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCApplicationStoppedTime 打印垃圾回收期间程序暂停的时间 -XX:PrintHeapAtGC 打印GC前后的详细堆栈信息
例如:
1.将堆的最大、最小设置为相同的值,目的是防止垃圾收集器在最小、最大之间收缩堆而产生额外的时间。
-Xmx3550m: 最大堆大小为3550m。
-Xms3550m: 设置初始堆大小为3550m。
2.在配置较好的机器上(比如多核、大内存),可以为老年代选择并行收集算法: -XX:+UseParallelOldGC 。
3.年轻代和老年代将根据默认的比例(1:2)分配堆内存, 可以通过调整二者之间的比率来调整二者之间的大小,也可以针对回收代。
比如年轻代,通过 -XX:newSize -XX:MaxNewSize来设置其绝对大小。同样,为了防止年轻代的堆收缩,我们通常会把-XX:newSize -XX:MaxNewSize设置为同样大小。
4.年轻代和老年代设置多大才算合理
1)更大的年轻代必然导致更小的老年代,大的年轻代会延长普通GC的周期,但会增加每次GC的时间;小的老年代会导致更频繁的Full GC
2)更小的年轻代必然导致更大老年代,小的年轻代会导致普通GC很频繁,但每次的GC时间会更短;大的老年代会减少Full GC的频率
如何选择应该依赖应用程序对象生命周期的分布情况: 如果应用存在大量的临时对象,应该选择更大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。但很多应用都没有这样明显的特性。
在抉择时应该根 据以下两点:
(1)本着Full GC尽量少的原则,让老年代尽量缓存常用对象,JVM的默认比例1:2也是这个道理 。
(2)通过观察应用一段时间,看其他在峰值时老年代会占多少内存,在不影响Full GC的前提下,根据实际情况加大年轻代,比如可以把比例控制在1:1。但应该给老年代至少预留1/3的增长空间。
在实际过程中,我们并不频繁调整JVM参数,保证能够使用就好,当然在日常的监控中我们可以观察一下jvm中gc的频率,FGC的大小,根据具体的场景进行选择!正常情况下非必要不要去尝试调整,否则线上问题会很头疼。
本文来自博客园,作者:青柠_fisher,转载请注明原文链接:https://www.cnblogs.com/oldEleven/p/17289884.html
以上是关于打怪升级容器关于Map的主要内容,如果未能解决你的问题,请参考以下文章