开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

HashMap面试题,看这一篇就够了! 序言 一、JDK7中

发表于 2019-12-05

更多技术文章,欢迎关注我的微信公众号:码不停蹄的小鼠松(微信号:busy_squirrel),也可扫下方二维码关注获取最新文章哦~

序言

在后端的日常开发工作中,集合是使用频率相当高的一个工具,而其中的HashMap,则更是我们用以处理业务逻辑的好帮手,同时HashMap的底层实现和原理,也成了面试题中的常客。

以前曾有详细了解过HashMap的实现原理,看过源码(JDK7版本)。但随着jdk版本的飞速迭代(现在都到JDK13了,但新特性还从没用过。。),主流的jdk使用版本也终于从JDK7挪到了JDK8。

由于JDK的向前兼容,在JDK8的使用过程中也没发现HashMap有什么特别之处,特性并无变化(依然线程不安全)。但最近的一次好奇心驱使,从IDE中点进去看了下HashMap的put()方法,有点儿懵逼,怎么跟我记忆中的不太一样?从JDK7到JDK8,HashMap也做了升级么?升级了什么哪些内容?

借着这股好奇心,把JDK7和JDK8的源码都翻了翻,对两者的实现原理做一下对比,JDK版本都在半年左右一次的速度推陈出新,我们的认知当然也要跟上,不断学习,站在浪潮之巅,不然就要被这滚滚的信息泥石流给裹挟淹没了。

先展示下Map家族的关系层级,有助于我们更好的理解后面的内容。

HashMap的基本知识点介绍就不多啰嗦了,直奔主题,看JDK7和JDK8的功能实现吧。

一、JDK7中的HashMap底层实现

1.1 基础知识

不管是1.7,还是1.8,HashMap的实现框架都是哈希表 + 链表的组合方式。结构图如下:

平常使用最多的就是put()、get()操作,想要了解底层实现,最直接的就是从put()/get()方法看起。不过在具体看源码前,我们先关注几个域变量,打打基础,如下:

上图中,已对各个变量做了简单的解释。
再多说一下,最后一个变量modCount,记录了map新增/删除k-v对,或者内部结构做了调整的次数,其主要作用,是对Map的iterator()操作做一致性校验,如果在iterator操作的过程中,map的数值有修改,直接抛出ConcurrentModificationException异常。

还需要说明的是,上面的域变量中存在一个等式:

1
复制代码threshold = table.length * loadFactor;

当执行put()操作放入一个新的值时,如果map中已经存在对应的key,则作替换即可,若不存在,则会首先判断size>=threshold是否成立,这是决定哈希table是否扩容的重要因素。

就使用层面来说,用的最多的莫过于put()方法、get()方法。想要详细了解运作原理,那就先从这两个方法看起吧,这两个方法弄明白了,也就基本能理清HashMap的实现原理了。

1.2 put()方法

当了解了以上的变量和用途后,接下来看下put()方法的具体实现:

如上面的截图代码所示,整个put方法的处理过程,可拆分为四部分:

  • part1:特殊key值处理,key为null;
  • part2:计算table中目标bucket的下标;
  • part3:指定目标bucket,遍历Entry结点链表,若找到key相同的Entry结点,则做替换;
  • part4:若未找到目标Entry结点,则新增一个Entry结点。

不知大家有没有发现,上面截图中的put()方法是有返回值的,场景区分如下:

  • 场景1:若执行put操作前,key已经存在,那么在执行put操作时,会使用本次的新value值来覆盖前一次的旧value值,返回的就是旧value值;
  • 场景2:若key不存在,则返回null值。

下面对put方法的各部分做详细的拆解分析。

1.2.1 特殊key值处理

特殊key值,指的就是key为null。
先说结论:

a) HashMap中,是允许key、value都为null的,且key为null只存一份,多次存储会将旧value值覆盖;

b) key为null的存储位置,都统一放在下标为0的bucket,即:table[0]位置的链表;

c) 如果是第一次对key=null做put操作,将会在table[0]的位置新增一个Entry结点,使用头插法做链表插入。

上代码:

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
复制代码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;
}


/**
* 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);
}


/**
* Like addEntry except that this version is used when creating entries
* as part of Map construction or "pseudo-construction" (cloning,
* deserialization). This version needn't worry about resizing the table.
*
* Subclass overrides this to alter the behavior of HashMap(Map),
* clone, and readObject.
*/
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++;
}

putForNullKey()方法中的代码较为简单:首先选择table[0]位置的链表,然后对链表做遍历操作,如果有结点的key为null,则将新value值替换掉旧value值,返回旧value值,如果未找到,则新增一个key为null的Entry结点。

重点我们看下第二个方法addEntry()。
这是一个通用方法:

给定hash、key、value、bucket下​标,新增一个Entry结点,另外还担负了扩容职责。如果哈希表中存放的k-v对数量超过了当前阈值(threshold = table.length * loadFactor),且当前的bucket下标有链表存在,那么就做扩容处理(resize)。扩容后,重新计算hash,最终得到新的bucket下标,然后使用头插法新增结点。

1.2.2 扩容

上一节有提及,当k-v对的容量超出一定限度后,需要对哈希table做扩容操作。那么问题来了,怎么扩容的?
下面看下源代码:

有两个核心点:
a) 扩容后大小是扩容前的2倍;

1
2
复制代码oldCapacity=table.length;
newCapacity = 2 * oldCapacity;

b) 数据搬迁,从旧table迁到扩容后的新table。
为避免碰撞过多,先决策是否需要对每个Entry链表结点重新hash,然后根据hash值计算得到bucket下标,然后使用头插法做结点迁移。

1.2.3 如何计算bucket下标?

① hash值的计算

首先得有key的hash值,就是一个整数,int类型,其计算方式使用了一种可尽量减少碰撞的算式(高位运算),具体原理不再展开,只要知道一点就行:使用key的hashCode作为算式的输入,得到了hash值。

从以上知识点,我们可以得到一个推论:

对于两个对象,若其hashCode相同,那么两个对象的hash值就一定相同。

这里还牵涉到另外一个知识点。对于HashMap中key的类型,必须满足以下的条件:

若两个对象逻辑相等,那么他们的hashCode一定相等,反之却不一定成立。

逻辑相等的含义就比较宽泛了,我们可以将逻辑的相等定义为两个对象的内存地址相同,也可以定义为对象的某个域值相等,自定义两个对象的逻辑相等,可通过重写Object类的equals()方法来实现。
比如String类,请看以下代码:

1
2
3
4
复制代码String str1 = "abc";
String str2 = new String("abc");
System.out.println(str1 == str2); // false,两个对象的内存地址并不同
System.out.println(str1.equals(str2)); // true 两个对象的域值相同,都存储了 abc 这三个字符

对于上面代码中的str1、str2两个对象,虽然它们的内存地址不同,但根据String类中对Object类的equals()方法的重写(@override),两个对象的域变量(即char数组)都存储了’a’、’b’、’c’三个字符,因此逻辑上是相等的。既然str1、str2两个对象逻辑上相等,那么一定有如下结果:

1
2
3
4
5
复制代码System.out.println(str1.hashCode() == str2.hashCode());


---输出---
true

从而我们就可以知道,在同一个HashMap对象中,会有如下结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码String str1 = "abc";
String str2 = new String("abc");
Map<String, Integer> testMap = new HashMap<>();
testMap.put(str1, 12);
testMap.put(str2, 13);


String str3 = new StringBuilder("ab").append("c").toString();
System.out.println(testMap.get(str3));


---输出---
13

另外,我们也可以反过来想一下。

假设HashMap的key不满足上面提到的条件,即:两个对象相等的情况下,他们的hashCode可能不一致。那么,这会带来什么后果呢?以上面示例代码中的str1、str2为例,若它们的hashCode不相等,那么对应的hash也就可能不相等(注意:这里是可能不相等,也有可能相等),testMap做put操作时,str1、str2为就会被分配到不同的bucket上,导致的最直接后果就是会存储两份。间接的后果那就更多了,比如:使用str3对象执行testMap.get(str3)操作时,可能获取不到值,更进一步的后果就是这部分无法触达的对象无法回收,导致内存泄漏。

因此,再重新一遍,HashMap的key所对应的类型,一定要满足如下条件:

若两个对象逻辑相等,那么他们的hashCode一定相等,反之却不一定成立。

② 取模的逻辑

前面我们分析了hash值的计算,接下来就可以引出bucket下标的计算:

1
2
3
4
5
6
复制代码/**
* Returns index for hash code h.
*/
static int indexFor(int h, int length) {
return h & (length-1);
}

计算相当简洁:将table的容量与hash值做“与”运算,得到哈希table的bucket下标。

③ 拓展

这种通俗的不能再通俗的计算大家都能看懂,但为何要这样做呢?背后的思考是什么?在看到下面的解释前,大家可以先思考下~

在文档开头,给出了HashMap类中的各个域变量。其中,哈希table的初始大小默认设置为16,为2的次幂数。后面在扩容时,都是以2的倍数来扩容。为什么非要将哈希table的大小控制为2的次幂数?

原因1:降低发生碰撞的概率,使散列更均匀。根据key的hash值计算bucket的下标位置时,使用“与”运算公式:h & (length-1),当哈希表长度为2的次幂时,等同于使用表长度对hash值取模(不信大家可以自己演算一下),散列更均匀;

原因2:表的长度为2的次幂,那么(length-1)的二进制最后一位一定是1,在对hash值做“与”运算时,最后一位就可能为1,也可能为0,换句话说,取模的结果既有偶数,又有奇数。设想若(length-1)为偶数,那么“与”运算后的值只能是0,奇数下标的bucket就永远散列不到,会浪费一半的空间。

1.2.4 在目标bucket中遍历Entry结点

先把这部分代码拎出来:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码...
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;
}
}
...

通过hash值计算出下标,找到对应的目标bucket,然后对链表做遍历操作,逐个比较,如下:

注意这里的查找条件:e.hash == hash && ((k = e.key) == key || key.equals(k))
结点的key与目标key的相等,要么内存地址相等,要么逻辑上相等,两者有一个满足即可。

1.3 get()方法

相比于put()方法,get()方法的实现就相对简单多了。主要分为两步,先是通过key的hash值计算目标bucket的下标,然后遍历对应bucket上的链表,逐个对比,得到结果。

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
复制代码public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);


return null == entry ? null : entry.getValue();
}


/**
* Returns the entry associated with the specified key in the
* HashMap. Returns null if the HashMap contains no mapping
* for the key.
*/
final Entry<K,V> getEntry(Object key) {
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;
}

1.4 Map中的迭代器Iterator

1.4.1 Map遍历的几种方式

先问个问题,你能想到几种遍历Map的方式?

方式1:Iterator迭代器

1
2
3
4
5
复制代码Iterator<Entry<String, Integer>> iterator = testMap.entrySet().iterator();
while (iterator.hasNext()) {
Entry<String, Integer> next = iterator.next();
System.out.println(next.getKey() + ":" + next.getValue());
}

逐个获取哈希table中的每个bucket中的每个Entry结点,后面会详细介绍。

方式2:最常见的使用方式,可同时得到key、value值

1
2
3
4
复制代码// 方式一
for (Map.Entry<String, Integer> entry : testMap.entrySet()) {
System.out.println(entry.getKey() + ":" + entry.getValue());
}

这种方式是一个语法糖,我们可通过反编译命令javap,或通过IDE来查下编译之后的语句:

1
2
3
4
5
复制代码Iterator var2 = testMap.entrySet().iterator();
while(var2.hasNext()) {
Entry<String, Integer> entry = (Entry)var2.next();
System.out.println((String)entry.getKey() + ":" + entry.getValue());
}

其底层还是使用的是Iterator功能。

方式3:使用foreach方式(JDK1.8才有)

1
2
3
复制代码testMap.forEach((key, value) -> {
System.out.println(key + ":" + value);
});

这是一种Lambda表达式。foreach也是一个语法糖,其内部是使用了方式二的处理方式,Map的foreach方法实现如下:

方式4:通过key的set集合遍历

1
2
3
4
5
复制代码Iterator<String> keyIterator = testMap.keySet().iterator();
while (keyIterator.hasNext()) {
String key = keyIterator.next();
System.out.println(key + ":" + testMap.get(key));
}

这种也是Iterator的方式,不过是通过Set类的iterator方式。

相比方式1,这种方式在获取value时,还需要再次通过testMap.get()的方式,性能相比方式1要降低很多。但两者有各自的使用场景,若在Map的遍历中仅使用key,则方式4较为适合,若需用到value,推荐使用方式1。

从前面的方式1和方式2可知,方式4还有如下的变体(语法糖的方式):

1
2
3
复制代码for (String key : testMap.keySet()) {
System.out.println(key + ":" + testMap.get(key));
}

综合以上,在遍历Map时,从性能方面考虑,若需同时使用key和value,推荐使用方式1或方式2,若单纯只是使用key,推荐使用方式4。任何情况下都不推荐使用方式3,因为会新增二次查询(通过key再一次在Map中查找value)。

另外,使用方式1时,还可以做remove操作,这个下面会讲到。

1.4.2 Iterator的实现原理

先看一张类/接口的继承关系图:

Iterator为一个顶层接口,只提供了三个基础方法声明:

1
2
3
4
5
6
7
8
9
10
复制代码public interface Iterator<E> {

boolean hasNext();

E next();

default void remove() {
throw new UnsupportedOperationException("remove");
}
}

这也是我们使用Iterator时绕不开的三个方法。
在HashMap中,首先是新增了一个内部抽象类HashIterator,如下:

我们以Entry结点的遍历为例(map的key、value的Iterator遍历方式都类似):

1
2
3
4
5
复制代码Iterator<Entry<String, Integer>> iterator = testMap.entrySet().iterator();
while (iterator.hasNext()) {
Entry<String, Integer> next = iterator.next();
System.out.println(next.getKey() + ":" + next.getValue());
}

首先,第一行代码,找到Iterator接口的具体实现类EntryIterator:

1
2
3
4
5
复制代码private final class EntryIterator extends HashIterator<Map.Entry<K,V>> {
public Map.Entry<K,V> next() {
return nextEntry();
}
}

非常简洁有木有???就只有一个next()方法,其正是对Iterator接口的next()方法的实现。方法内部也只有一行,指向了父类的nextEntry()方法,即上面截图中的HashIterator类中的nextEntry()方法。

HashMap中的Iterator实现原理也不过如此,就是这么朴实无华,是不是都想动手自己撸一个HashMap的实现了?嗯,你可以的!!!

1.5 fail-fast策略

和fail-fast经常一起出现的还有一个异常类ConcurrentModificationException,接下来我们聊下这两者是什么关系,以及为什么搞这么个策略出来。

什么是fail-fast?我们可以称它为”快速失效策略”,下面是Wikipedia中的解释:

In systems design, a fail-fast system is one which immediately reports at its interface any condition that is likely to indicate a failure. Fail-fast systems are usually designed to stop normal operation rather than attempt to continue a possibly flawed process.
Such designs often check the system’s state at several points in an operation, so any failures can be detected early. The responsibility of a fail-fast module is detecting errors, then letting the next-highest level of the system handle them.

大白话翻译过来,就是在系统设计中,当遇到可能会诱导失败的条件时立即上报错误,快速失效系统往往被设计在立即终止正常操作过程,而不是尝试去继续一个可能会存在错误的过程。

再简洁点说,就是尽可能早的发现问题,立即终止当前执行过程,由更高层级的系统来做处理。

在HashMap中,我们前面提到的modCount域变量,就是用于实现hashMap中的fail-fast。出现这种情况,往往是在非同步的多线程并发操作。

在对Map的做迭代(Iterator)操作时,会将modCount域变量赋值给expectedModCount局部变量。在迭代过程中,用于做内容修改次数的一致性校验。若此时有其他线程或本线程的其他操作对此Map做了内容修改时,那么就会导致modCount和expectedModCount不一致,立即抛出异常ConcurrentModificationException。

举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码public static void main(String[] args) {
Map<String, Integer> testMap = new HashMap<>();
testMap.put("s1", 11);
testMap.put("s2", 22);
testMap.put("s3", 33);


for (Map.Entry<String, Integer> entry : testMap.entrySet()) {
String key = entry.getKey();
if ("s1".equals(key)) {
testMap.remove(key);
}
}
}


---- output ---
Exception in thread "main" java.util.ConcurrentModificationException
at java.util.HashMap$HashIterator.nextNode(HashMap.java:1437)
at java.util.HashMap$EntryIterator.next(HashMap.java:1471)
at java.util.HashMap$EntryIterator.next(HashMap.java:1469)
...

正确的删除Map元素的姿势:只有一个,Iteator的remove()方法。

1
2
3
4
5
6
7
8
9
复制代码// 方式三
Iterator<Entry<String, Integer>> iterator = testMap.entrySet().iterator();
while (iterator.hasNext()) {
Entry<String, Integer> next = iterator.next();
System.out.println(next.getKey() + ":" + next.getValue());
if (next.getKey().equals("s2")) {
iterator.remove();
}
}

但也要注意一点,能安全删除,并不代表就是多线程安全的,在多线程并发执行时,若都执行上面的操作,因未设置为同步方法,也可能导致modCount与expectedModCount不一致,从而抛异常ConcurrentModificationException。
线程不安全的体现和规避方式,后续章节会详细提及。

二、JDK8中的HashMap底层实现

前面我们已经详细剖析了HashMap在JDK7中的实现,不知大家有没有发现其中可以优化的地方?比如哈希表中因为hash碰撞而产生的链表结构,如果数据量很大,那么产生碰撞的几率很增加,这带来的后果就是链表长度也一直在增加,对于查询来说,性能会越来越低。如何提升查询性能,成了JDK8中的HashMap要解决的问题。

因此,相比于JDK7,HashMap在JDK8中做链表结构做了优化(但仍然线程不安全),在一定条件下将链表转为红黑树,提升查询效率。

JDK8中的HashMap其底层存储结构如下:

相比于JDK7,JDK8中的HashMap会将较长的链表转为红黑树,这也是与JDK7的核心差异。下面先看下put()方法的实现。

2.1 put()操作

在进一步分析put()操作前,先说明一下:除了底层存储结构有调整,链表结点的定义也由Entry类转为了Node类,但内核没有变化,不影响理解。

先上源代码:

是不是很长很复杂?其实不难,只要记住上面的底层存储结构图,代码就很容易看懂。还是一样的存储套路,先根据key确定在哈希table中的下标,找到对应的bucket,遍历链表(或红黑树),做插入操作。在JDK7中,新增结点是使用头插法,但在JDK8中,在链表使用尾插法,将待新增结点追加到链表末尾。

为方便理解,将上面的代码转为了下面的流程图:

步骤①:若哈希table为null,或长度为0,则做一次扩容操作;
步骤②:根据index找到目标bucket后,若当前bucket上没有结点,那么直接新增一个结点,赋值给该bucket;
步骤③:若当前bucket上有链表,且头结点就匹配,那么直接做替换即可;
步骤④:若当前bucket上的是树结构,则转为红黑树的插入操作;
步骤⑤:若步骤①、②、③、④都不成立,则对链表做遍历操作。
a) 若链表中有结点匹配,则做value替换;
b)若没有结点匹配,则在链表末尾追加。同时,执行以下操作:
i) 若链表长度大于TREEIFY_THRESHOLD,则执行红黑树转换操作;
ii) 若条件i) 不成立,则执行扩容resize()操作。
以上5步都执行完后,再看当前Map中存储的k-v对的数量是否超出了threshold,若超出,还需再次扩容。

红黑树的转换操作如下:

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
复制代码/**
* Replaces all linked nodes in bin at index for given hash unless
* table is too small, in which case resizes instead.
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 若表为空,或表长度小于MIN_TREEIFY_CAPACITY,也不做转换,直接做扩容处理。
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}

2.2 扩容操作

什么场景下会触发扩容?

场景1:哈希table为null或长度为0;

场景2:Map中存储的k-v对数量超过了阈值threshold;

场景3:链表中的长度超过了TREEIFY_THRESHOLD,但表长度却小于MIN_TREEIFY_CAPACITY。

一般的扩容分为2步,第1步是对哈希表长度的扩展(2倍),第2步是将旧table中的数据搬到新table上。

那么,在JDK8中,HashMap是如何扩容的呢?

上源代码片段:

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
复制代码...
// 前面已经做了第1步的长度拓展,我们主要分析第2步的操作:如何迁移数据
table = newTab;
if (oldTab != null) {
// 循环遍历哈希table的每个不为null的bucket
// 注意,这里是"++j",略过了oldTab[0]的处理
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 若只有一个结点,则原地存储
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// "lo"前缀的代表要在原bucket上存储,"hi"前缀的代表要在新的bucket上存储
// loHead代表是链表的头结点,loTail代表链表的尾结点
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 以oldCap=8为例,
// 0001 1000 e.hash=24
// & 0000 1000 oldCap=8
// = 0000 1000 --> 不为0,需要迁移
// 这种规律可发现,[oldCap, (2*oldCap-1)]之间的数据,
// 以及在此基础上加n*2*oldCap的数据,都需要做迁移,剩余的则不用迁移
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;
// 需要搬迁的结点,新下标为从当前下标往前挪oldCap个距离。
newTab[j + oldCap] = hiHead;
}
}
}
}
}

2.3 get()操作

了解了上面的put()操作,get()操作就比较简单了。

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 V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}


final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((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 {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}

先根据key计算hash值,进一步计算得到哈希table的目标index,若此bucket上为红黑树,则再红黑树上查找,若不是红黑树,遍历链表。

三、HashMap、HashTable是什么关系?

再把文章开头的这张图放出来,温习一下:

3.1 共同点与异同点

共同点:

  • 底层都是使用哈希表 + 链表的实现方式。

区别:

  • 从层级结构上看,HashMap、HashTable有一个共用的Map接口。另外,HashTable还单独继承了一个抽象类Dictionary;
  • HashTable诞生自JDK1.0,HashMap从JDK1.2之后才有;
  • HashTable线程安全,HashMap线程不安全;
  • 初始值和扩容方式不同。HashTable的初始值为11,扩容为原大小的2*d+1。容量大小都采用奇数且为素数,且采用取模法,这种方式散列更均匀。但有个缺点就是对素数取模的性能较低(涉及到除法运算),而HashTable的长度都是2的次幂,设计就较为巧妙,前面章节也提到过,这种方式的取模都是直接做位运算,性能较好。
  • HashMap的key、value都可为null,且value可多次为null,key多次为null时会覆盖。当HashTable的key、value都不可为null,否则直接NPE(NullPointException)。

示例:

1
2
3
4
5
复制代码public static void main(String[] args) {
Map<String, Integer> testTable = new Hashtable<>();
testTable.put(null, 23); // 抛NPE
testTable.put("s1", null); // 抛NPE
}

看下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
复制代码public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}


// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}


addEntry(hash, key, value, index);
return null;
}

源码中不允许value为null,若为null则直接抛NPE。

对于key为null时,源码第9行:int hash = key.hashCode(); 未做判空操作,也会外抛NPE。

另外,我们现在看到的抽象类Dictionary,是一个已经废弃的类,源码注释中有如下说明:

1
2
复制代码<strong>NOTE: This class is obsolete.  New implementations should
implement the Map interface, rather than extending this class.</strong>

3.2 HashMap的线程安全

能保证线程线程安全的方式有多个,比如添加synchronized关键字,或者使用lock机制。两者的差异不在此展开,后续会写有关线程安全的文章,到时再详细说明。而HashTable使用了前者,即synchronized关键字。

put操作、get操作、remove操作、equals操作,都使用了synchronized关键字修饰。

这么做是保证了HashTable对象的线程安全特性,但同样也带来了问题,突出问题就是效率低下。为何会说它效率低下呢?

因为按synchronized的特性,对于多线程共享的临界资源,同一时刻只能有一个线程在占用,其他线程必须原地等待,为方便理解,大家不妨想下计时用的沙漏,中间最细的瓶颈处阻挡了上方细沙的下落,同样的道理,当有大量线程要执行get()操作时,也存在此类问题,大量线程必须排队一个个处理。

这时可能会有人说,既然get()方法只是获取数据,并没有修改Map的结构和数据,不加不就行了吗?不好意思,不加也不行,别的方法都加,就你不加,会有一种场景,那就是A线程在做put或remove操作时,B线程、C线程此时都可以同时执行get操作,可能哈希table已经被A线程改变了,也会带来问题,因此不加也不行。

现在好了,HashMap线程不安全,HashTable虽然线程安全,但性能差,那怎么破?使用ConcurrentHashMap类吧,既线程安全,还操作高效,谁用谁说好。莫急,下面章节会详细解释ConcurrentHashMap类。

四、HashMap线程不安全在哪?

本章节主要探讨下HashMap的线程不安全会带来哪些方面的问题。

4.1 数据覆盖问题

两个线程执行put()操作时,可能导致数据覆盖。JDK7版本和JDK8版本的都存在此问题,这里以JDK7为例。

假设A、B两个线程同时执行put()操作,且两个key都指向同一个buekct,那么此时两个结点,都会做头插法。
先看这里的代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码public V put(K key, V value) {
...
addEntry(hash, key, value, i);
}


void addEntry(int hash, K key, V value, int bucketIndex) {
...
createEntry(hash, key, value, bucketIndex);
}


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++;
}

看下最后的createEntry()方法,首先获取到了bucket上的头结点,然后再将新结点作为bucket的头部,并指向旧的头结点,完成一次头插法的操作。

当线程A和线程B都获取到了bucket的头结点后,若此时线程A的时间片用完,线程B将其新数据完成了头插法操作,此时轮到线程A操作,但这时线程A所据有的旧头结点已经过时了(并未包含线程B刚插入的新结点),线程A再做头插法操作,就会抹掉B刚刚新增的结点,导致数据丢失。

其实不光是put()操作,删除操作、修改操作,同样都会有覆盖问题。

4.2 扩容时导致死循环

这是最常遇到的情况,也是面试经常被问及的考题。但说实话,这个多线程环境下导致的死循环问题,并不是那么容易解释清楚,因为这里已经深入到了扩容的细节。这里尽可能简单的描述死循环的产生过程。

另外,只有JDK7及以前的版本会存在死循环现象,在JDK8中,resize()方式已经做了调整,使用两队链表,且都是使用的尾插法,及时多线程下,也顶多是从头结点再做一次尾插法,不会造成死循环。而JDK7能造成死循环,就是因为resize()时使用了头插法,将原本的顺序做了反转,才留下了死循环的机会。

在进一步说明死循环的过程前,我们先看下JDK7中的扩容代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码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;
}
}
}

其实就是简单的链表反转,再进一步简化的话,分为当前结点e,以及下一个结点e.next。我们以链表a->b->c->null为例,两个线程A和B,分别做扩容操作。

原表:

线程A和B各自新增了一个新的哈希table,在线程A已做完扩容操作后,线程B才开始扩容。此时对于线程B来说,当前结点e指向a结点,下一个结点e.next仍然指向b结点(此时在线程A的链表中,已经是c->b->a的顺序)。按照头插法,哈希表的bucket指向a结点,此时a结点成为线程B中链表的头结点,如下图所示:

a结点成为线程B中链表的头结点后,下一个结点e.next为b结点。既然下一个结点e.next不为null,那么当前结点e就变成了b结点,下一个结点e.next变为a结点。继续执行头插法,将b变为链表的头结点,同时next指针指向旧的头节点a,如下图:

此时,下一个结点e.next为a节点,不为null,继续头插法。指针后移,那么当前结点e就成为了a结点,下一个结点为null。将a结点作为线程B链表中的头结点,并将next指针指向原来的旧头结点b,如下图所示:

此时,已形成环链表。同时下一个结点e.next为null,流程结束。

4.3 小结

多线程环境下使用HashMap,会引起各类问题,上面仅为不安全问题的两个典型示例,具体问题无法一一列举,但大体会分为以下三类:

  • 死循环
  • 数据重复
  • 数据丢失(覆盖)

在JDK1.5之前,多线程环境往往使用HashTable,但在JDK1.5及以后的版本中,在并发包中引入了专门用于多线程环境的ConcurrentHashMap类,采用分段锁实现了线程安全,相比HashTable有更高的性能,推荐使用。

五、如何规避HashMap的线程不安全?

前面提到了HashMap在多线程环境下的各类不安全问题,那么有哪些方式可以转成线程安全的呢?

5.1 将Map转为包装类

如何转?使用Collections.SynchronizedMap()方法,示例代码:

1
2
3
4
复制代码Map<String, Integer> testMap = new HashMap<>();
...
// 转为线程安全的map
Map<String, Integer> map = Collections.synchronizedMap(testMap);

其内部实现也很简单,等同于HashTable,只是对当前传入的map对象,新增对象锁(synchronized):

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
复制代码// 源码来自Collections类


private static class SynchronizedMap<K,V> implements Map<K,V>, Serializable {
private static final long serialVersionUID = 1978198479659022715L;


private final Map<K,V> m; // Backing Map
final Object mutex; // Object on which to synchronize


SynchronizedMap(Map<K,V> m) {
this.m = Objects.requireNonNull(m);
mutex = this;
}


SynchronizedMap(Map<K,V> m, Object mutex) {
this.m = m;
this.mutex = mutex;
}


public int size() {
synchronized (mutex) {return m.size();}
}
public boolean isEmpty() {
synchronized (mutex) {return m.isEmpty();}
}
public boolean containsKey(Object key) {
synchronized (mutex) {return m.containsKey(key);}
}
public boolean containsValue(Object value) {
synchronized (mutex) {return m.containsValue(value);}
}
public V get(Object key) {
synchronized (mutex) {return m.get(key);}
}


public V put(K key, V value) {
synchronized (mutex) {return m.put(key, value);}
}
public V remove(Object key) {
synchronized (mutex) {return m.remove(key);}
}
public void putAll(Map<? extends K, ? extends V> map) {
synchronized (mutex) {m.putAll(map);}
}
public void clear() {
synchronized (mutex) {m.clear();}
}
}

5.2 使用ConcurrentHashMap

既然HashMap类是线程不安全的,那就不妨找个线程安全的替代品——ConcurrentHashMap类。

使用示例:

1
2
3
4
复制代码Map<String, Integer> susuMap = new ConcurrentHashMap<>();
susuMap.put("susu1", 111);
susuMap.put("susu2", 222);
System.out.println(susuMap.get("susu1"));

在使用习惯上完全兼容了HashMap的使用。

JDK1.5版本引入,位于并发包java.util.concurrent下。

在JDK7版本及以前,ConcurrentHashMap类使用了分段锁的技术(segment + Lock),但在jdk8中,也做了较大改动,使用回了synchronized修饰符。
具体差别,在以后的文章中再详细介绍。

文章较长,希望能对在看的你有所帮助。

更多技术文章,欢迎关注我的微信公众号:码不停蹄的小鼠松(微信号:busy_squirrel),也可扫下方二维码关注获取最新文章哦~

Reference

1、Java 8系列之重新认识HashMap: tech.meituan.com/2016/06/24/…
2、fail-fast是个什么策略?:blog.chinaunix.net/uid-3150784…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

你知道你对 JSON Web Token 的认识存在误解吗

发表于 2019-12-05

1.前言

JSON Web Token (JWT) 其实目前已经广为软件开发者所熟知了,但是 JOSE (Javascript Object Signing and Encryption) 却鲜有人知道,我第一次知道它是在 Spring Security 的官方文档中,它改变了我对 JWT 的一些认识。目前国内能找到相关中文资料不是太多。所以我觉得有必要归纳一下。

  1. JOSE 概述

JOSE 是一种旨在提供在各方之间安全传递声明(claims)的方法的规范集。我们常用的 JWT 就包含了允许客户端访问特定应用下特定资源的声明。JOSE 制定了一系列的规范来达到此目的。目前该规范还在不断的发展,我们常用的包含以下几个 RFC :

  • JWS(RFC 7515) -JSON Web签名,描述生成和处理签名消息
  • JWE(RFC 7516) -JSON Web加密,描述了保护和处理加密 消息
  • JWK(RFC 7517) -JSON Web密钥,描述 Javascript 对象签名和加密中加密密钥的 格式和处理
  • JWA(RFC 7518) -JSON Web算法,描述了 Javascript 对象签名和加密中使用的 加密 算法
  • JWT(RFC 7519) -JSON Web令牌,描述以 JSON 编码并由 JWS 或 JWE 保护的声明的表示形式
  1. 我们都看错了 JWT

看了对 JWT 的描述中提到 “令牌以 JWS 或者 JWE 声明表示”。莫非我之前的认知是错误的吗? 找了一些官方的资料研究了一番后,确实我之前的认知是不够全面的。

官方定义:

JSON Web Token (JWT) is a compact URL-safe means of representing claims to be transferred between two parties

直译过来:JSON Web令牌(JWT)是一种紧凑的URL安全方法,用于表示要在两方之间转移的声明。

也就是说我们通常说的 JWT 实际上是一个对声明进行 JOSE 处理方式的统称。我们之前用的应该叫 JWS(JSON Web Signature),是 JWT 的一种实现,除了 JWS , JWT 还有另一种实现 JWE(JSON Web Encryption) 。它们之间的关系应该是这样的:

  1. 什么是 JWE

JWS 我们就不说了,就是通常我们所说的 JWT。包括之前我在 Spring Security 实战干货 中所涉及到的 JWT 都是 JWS。我们来说一下 JWE 。JWS 仅仅是对声明(claims)作了签名,保证了其不被篡改,但是其 payload(中段负载) 信息是暴露的。也就是 JWS 仅仅能保证数据的完整性而不能保证数据不被泄露。所以我以前也说过它不适合传递敏感数据。JWE 的出现就是为了解决这个问题的。具体的可以看下图:

从上面可以看出 JWE 的生成非常繁琐,作为 Token 可能比较消耗资源和耗时。用作安全的数据传输途径应该不错。

  1. Spring Security jose 相关

这里需要简单提一下 Spring Security 提供了 JOSE 有关的类库 spring-security-oauth2-jose ,你可以使用该类库来使用 JOSE 。如果 Java 开发者要在 Spring Security 安全框架中使用 OAuth2.0 ,这个类库也是需要研究一下的。

  1. 总结

今天我们对 JOSE 这个相对陌生的概念进行了认识,对 JOSE 规范集中的几个重要的 RFC 进行了列举。对之前的局限性认识也进行了纠正。为我们后续的 OAuth2.0 相关学习进行了铺垫。

关注公众号:Felordcn获取更多资讯

个人博客:https://felord.cn

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

基于jvmti定位java异常信息

发表于 2019-12-05

微信公众号:bugstack虫洞栈

沉淀、分享、成长,专注于原创专题案例,以最易学习编程的方式分享知识,让自己和他人都能有所收获。目前已完成的专题有;Netty4.x实战专题案例、用Java实现JVM、基于JavaAgent的全链路监控、手写RPC框架、架构设计专题案例[Ing]等。

背景描述

JVMTI(JVM Tool Interface)位于jpda最底层,是Java虚拟机所提供的native编程接口。JVMTI可以提供性能分析、debug、内存管理、线程分析等功能。

JPDA 定义了一个完整独立的体系,它由三个相对独立的层次共同组成,而且规定了它们三者之间的交互方式,或者说定义了它们通信的接口。这三个层次由低到高分别是 Java 虚拟机工具接口(JVMTI),Java 调试线协议(JDWP)以及 Java 调试接口(JDI)。这三个模块把调试过程分解成几个很自然的概念:调试者(debugger)和被调试者(debuggee),以及他们中间的通信器。被调试者运行于我们想调试的 Java 虚拟机之上,它可以通过 JVMTI 这个标准接口,监控当前虚拟机的信息;调试者定义了用户可使用的调试接口,通过这些接口,用户可以对被调试虚拟机发送调试命令,同时调试者接受并显示调试结果。在调试者和被调试着之间,调试命令和调试结果,都是通过 JDWP 的通讯协议传输的。所有的命令被封装成 JDWP 命令包,通过传输层发送给被调试者,被调试者接收到 JDWP 命令包后,解析这个命令并转化为 JVMTI 的调用,在被调试者上运行。类似的,JVMTI 的运行结果,被格式化成 JDWP 数据包,发送给调试者并返回给 JDI 调用。而调试器开发人员就是通过 JDI 得到数据,发出指令。

JDPA 模块层次.png

模块 层次 编程语言 作用
JVMTI 底层 C 获取及控制当前虚拟机状态
JDWP 中介层 C 定义 JVMTI 和 JDI 交互的数据格式
JDI 高层 Java 提供 Java API 来远程控制被调试虚拟机

Java 虚拟机工具接口(JVMTI)
JVMTI(Java Virtual Machine Tool Interface)即指 Java 虚拟机工具接口,它是一套由虚拟机直接提供的 native 接口,它处于整个 JPDA 体系的最底层,所有调试功能本质上都需要通过 JVMTI 来提供。通过这些接口,开发人员不仅调试在该虚拟机上运行的 Java 程序,还能查看它们运行的状态,设置回调函数,控制某些环境变量,从而优化程序性能。我们知道,JVMTI 的前身是 JVMDI 和 JVMPI,它们原来分别被用于提供调试 Java 程序以及 Java 程序调节性能的功能。在 J2SE 5.0 之后 JDK 取代了 JVMDI 和 JVMPI 这两套接口,JVMDI 在最新的 Java SE 6 中已经不提供支持,而 JVMPI 也计划在 Java SE 7 后被彻底取代。

Java 调试线协议(JDWP)
JDWP(Java Debug Wire Protocol)是一个为 Java 调试而设计的一个通讯交互协议,它定义了调试器和被调试程序之间传递的信息的格式。在 JPDA 体系中,作为前端(front-end)的调试者(debugger)进程和后端(back-end)的被调试程序(debuggee)进程之间的交互数据的格式就是由 JDWP 来描述的,它详细完整地定义了请求命令、回应数据和错误代码,保证了前端和后端的 JVMTI 和 JDI 的通信通畅。比如在 Sun 公司提供的实现中,它提供了一个名为 jdwp.dll(jdwp.so)的动态链接库文件,这个动态库文件实现了一个 Agent,它会负责解析前端发出的请求或者命令,并将其转化为 JVMTI 调用,然后将 JVMTI 函数的返回值封装成 JDWP 数据发还给后端。

Java 调试接口(JDI)
JDI(Java Debug Interface)是三个模块中最高层的接口,在多数的 JDK 中,它是由 Java 语言实现的。 JDI 由针对前端定义的接口组成,通过它,调试工具开发人员就能通过前端虚拟机上的调试器来远程操控后端虚拟机上被调试程序的运行,JDI 不仅能帮助开发人员格式化 JDWP 数据,而且还能为 JDWP 数据传输提供队列、缓存等优化服务。从理论上说,开发人员只需使用 JDWP 和 JVMTI 即可支持跨平台的远程调试,但是直接编写 JDWP 程序费时费力,而且效率不高。因此基于 Java 的 JDI 层的引入,简化了操作,提高了开发人员开发调试程序的效率。

开发简述

基于jvmti提供的接口服务,运用C++代码(win32-add_library)在Agent_OnLoad里开发监控服务,并生成dll文件。开发完成后在java代码中加入agentpath,这样就可以监控到我们需要的信息内容。

环境准备

  1. Dev-C++
  2. JetBrains CLion 2018.2.3
  3. IntelliJ IDEA Community Edition 2018.3.1 x64
  4. jdk1.8 64位
  5. jvmti(在jdk安装目录下jdk1.8.0_45\include里,复制到和工程案例同层级目录下)

配置信息(路径相关修改为自己的)

  1. C++开发工具Clion配置
    1. 配置位置;Settings->Build,Execution,Deployment->Toolchains
    2. MinGM配置:D:\Program Files (x86)\Dev-Cpp\MinGW64
  2. java调试时配置
    1. 配置位置:Run/Debug Configurations ->VM options
    2. 配置内容:-agentpath:E:\itstack\itstack.org\demo\jvmti\itstack-demo-jvmti-dll\cmake-build-debug\libitstack_demo_jvmti_dll.dll

代码示例

c++ 代码块:

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
复制代码#include <iostream>
#include <cstring>
#include "jvmti.h"

using namespace std;

//异常回调函数
static void JNICALL callbackException(jvmtiEnv *jvmti_env, JNIEnv *env, jthread thr, jmethodID methodId, jlocation location, jobject exception, jmethodID catch_method, jlocation catch_location) {

// 获得方法对应的类
jclass clazz;
jvmti_env->GetMethodDeclaringClass(methodId, &clazz);

// 获得类的签名
char *class_signature;
jvmti_env->GetClassSignature(clazz, &class_signature, nullptr);

//过滤非本工程类信息
string::size_type idx;
string class_signature_str = class_signature;
idx = class_signature_str.find("org/itstack");
if (idx != 1) {
return;
}

//异常类名称
char *exception_class_name;
jclass exception_class = env->GetObjectClass(exception);
jvmti_env->GetClassSignature(exception_class, &exception_class_name, nullptr);

// 获得方法名称
char *method_name_ptr, *method_signature_ptr;
jvmti_env->GetMethodName(methodId, &method_name_ptr, &method_signature_ptr, nullptr);

//获取目标方法的起止地址和结束地址
jlocation start_location_ptr; //方法的起始位置
jlocation end_location_ptr; //用于方法的结束位置
jvmti_env->GetMethodLocation(methodId, &start_location_ptr, &end_location_ptr);

//输出测试结果
cout << "测试结果-定位类的签名:" << class_signature << endl;
cout << "测试结果-定位方法信息:" << method_name_ptr << " -> " << method_signature_ptr << endl;
cout << "测试结果-定位方法位置:" << start_location_ptr << " -> " << end_location_ptr + 1 << endl;
cout << "测试结果-异常类的名称:" << exception_class_name << endl;

cout << "测试结果-输出异常信息(可以分析行号):" << endl;
jclass throwable_class = (*env).FindClass("java/lang/Throwable");
jmethodID print_method = (*env).GetMethodID(throwable_class, "printStackTrace", "()V");
(*env).CallVoidMethod(exception, print_method);

}

JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved) {
jvmtiEnv *gb_jvmti = nullptr;
//初始化
vm->GetEnv(reinterpret_cast<void **>(&gb_jvmti), JVMTI_VERSION_1_0);
// 创建一个新的环境
jvmtiCapabilities caps;
memset(&caps, 0, sizeof(caps));
caps.can_signal_thread = 1;
caps.can_get_owned_monitor_info = 1;
caps.can_generate_method_entry_events = 1;
caps.can_generate_exception_events = 1;
caps.can_generate_vm_object_alloc_events = 1;
caps.can_tag_objects = 1;
// 设置当前环境
gb_jvmti->AddCapabilities(&caps);
// 创建一个新的回调函数
jvmtiEventCallbacks callbacks;
memset(&callbacks, 0, sizeof(callbacks));
//异常回调
callbacks.Exception = &callbackException;
// 设置回调函数
gb_jvmti->SetEventCallbacks(&callbacks, sizeof(callbacks));
// 开启事件监听(JVMTI_EVENT_EXCEPTION)
gb_jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_EXCEPTION, nullptr);
return JNI_OK;
}

JNIEXPORT void JNICALL Agent_OnUnload(JavaVM *vm) {
}

java代码块:

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
复制代码package org.itstack.demo.jvmti;
import java.util.logging.Logger;

public class TestLocationException {

public static void main(String[] args) {
Logger logger = Logger.getLogger("TestLocationException");
try {
User resource = new User();
Object obj = resource.queryUserInfoById(null);
logger.info("测试结果:" + obj);
} catch (Exception e) {
//屏蔽异常
}
}
}

class User {
Logger logger = Logger.getLogger("User");
public Object queryUserInfoById(String userId) {
logger.info("根据用户Id获取用户信息" + userId);
if (null == userId) {
throw new NullPointerException("根据用户Id获取用户信息,空指针异常");
}
return userId;
}
}

测试结果

1
2
3
4
5
6
7
8
9
10
复制代码四月 13, 2019 12:21:45 下午 org.itstack.demo.jvmti.User queryUserInfoById
信息: 根据用户Id获取用户信息null
测试结果-定位类的签名:Lorg/itstack/demo/jvmti/User;
测试结果-定位方法信息:queryUserInfoById -> (Ljava/lang/String;)Ljava/lang/Object;
测试结果-定位方法位置:0 -> 43
测试结果-异常类的名称:Ljava/lang/NullPointerException;
测试结果-输出异常信息(可以分析行号):
java.lang.NullPointerException: 根据用户Id获取用户信息,空指针异常
at org.itstack.demo.jvmti.User.queryUserInfoById(TestLocationException.java:23)
at org.itstack.demo.jvmti.TestLocationException.main(TestLocationException.java:10)

其他内容:

  1. jvmti api
  2. JPDA 体系概览

微信公众号:bugstack虫洞栈,欢迎关注&获取源码

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

java并发编程实战笔记整理

发表于 2019-12-04

一、线程安全性

在线程安全性中,最核心的概念是正确性,而正确性的含义是:某个类的行为与其规范完全一致。这里的规范可以粗略理解为在各种限定条件下,类对象的结果与预期一致。在单线程中,正确性可以近似的定义为“所见即所知(we know it when we see it)”。在大概明确了“安全性”的概念后,我们可以认为线程安全性就是:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么这个类就可以认为是线程安全的。   

当多个线程访问某个类时,不管运行环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

也可以将线程安全类认为是一个在并发环境和单线程环境中都不会被破坏的类。如果某个类在单线程环境下都不是线程安全类,那么它肯定不是线程安全类。下面是一个线程安全类的示例:

1
2
3
4
5
6
7
复制代码public class StatelessFactorizer implements Servlet{
public void service(ServletRequest req, ServletResponse resp){
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
encodeIntoResponse(resp, factors);
}
}

这个StatelessFactorizer是无状态的:它既不包含任何域,也不包含任何对其他类中域的引用。方法中的局部变量只能由正在执行的线程访问。如果同时有多个线程在访问StatelessFactorizer,那么这些线程之间将不会互相影响,因为线程之间并没有共享状态,就好像在访问不同的实例。

由于线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因此无状态对象是线程安全的,且无状态对象一定是线程安全的。

二、原子性

什么是原子性呢?其实原子性就是一个不可再分割的性质,不能再分成更细的粒度。

如果我们在刚刚的示例中增加一个状态(既一个计数器),用来统计已处理请求数量,每处理一个请求就将这个值加1,程序如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码public class StatelessFactorizer implements Servlet{
private long count = 0;

public long getCount(){return count;}

public void service(ServletRequest req, ServletResponse resp){
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
++count;
encodeIntoResponse(resp, factors);
}
}

在上面的程序示例中,咋一看没问题,++count看起来像是一个操作,但是这个自增操作并非原子性的。因为实际上,它包含了三个操作:“读取-修改-写入”的操作序列。每个操作都依赖于前面之前的状态。如果此时有两个线程A、B,如果A线程已经进行到了修改操作,此时如果B线程进行了读取,那么最终A、B线程写入的值是一样的,这样就与预期结果偏差了1.

虽然在这里看起来,结果偏离了一些可以接受,但是如果这个计数器的值被用来生成数值序列或唯一的对象标识符,那么在多次调用中返回相同的值将导致严重的数据完整性问题。

在并发编程中,像这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,这种情况叫做“竞态条件(Race Condition)”。

竞态条件

当某个计算的正确性取决于多个线程的交替执行时序的时候,那么就会发生竞态条件。常见的竞态条件类型是“先检查后执行”操作,既通过一个可能失效的观测结果来决定下一步的动作。

举个栗子:你和朋友约好一起去网吧开黑,你当了网吧的时候,发现你朋友不在,此时你可能选择呆在网吧里等他,也可能去他家找他,如果你去找他,那么当你出了网吧以后,你在网吧的观测结果(朋友不在)就可能失效了,因为他可能在你去他家找他的路上已经到了网吧,而你却去找他了。

这个栗子中,正确的结果是(你们在网吧会面),但是这个结果取决于事件发生的时序(既谁先到网吧并且等待对方的时长)。这种观察结果的失效就是大多数竞态条件的本质——基于一种可能失效的观测结果来做出判断或者执行某个计算。

再举个栗子,假设有两个线程A、B,A、B线程都用来判断某个文件夹是否存在,不存在就创建它,假如当A线程发现文件夹不存在时,正打算创建文件夹,但是此时B线程已经完成了文件夹的创建,那么此时A线程观测的结果就已经失效了,但是A线程依旧根据这个已失效的观测结果在进行下一步动作,这就可能会导致各种问题。

使用“先检查后执行”的一种常见的情况就是延迟初始化。就比如在单例模式中有一种写法如下:

1
2
3
4
5
6
7
8
9
10
复制代码public class LazyInitRace {
private static LazyInitRace instance = null;

public LazyInitRace getInstance(){
if(instance == null){
instance = new LazyInitRace();
}
return instance;
}
}

这就是典型的延迟初始化,在单线程中这样写没毛病,但是在多线程环境中,如果有A、B线程同时执行getInstance()方法,那么结果可能符合预期,也可能会得到两个不一样的对象。因为在A线程发现instace为null时,B线程可能也同时发现instace为null。

与大多数并发错误一样,竞态条件并不总是会产生错误,还需要某种不恰当的执行时序,但是如果发生问题,那么可能导致很严重的问题。

在上面的示例中都包含了一组需要以原子方式执行(或者说不可分割)的操作。要避免竞态条件问题,就必须在某个线程修改变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改过程中。

在上面统计已处理请求数量的示例中,我们可以使用AtomicLong对象来替换long,因为AtmoicLong类是线程安全类,所以可以保证示例也是示例安全的,但是在添加一个状态变量时,是否还可以通过使用线程安全的对象来管理而类的状态以维护其线程安全性呢?如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码public class UnsafeCachingFactorizer implements Servlet {
private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>();

private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();

public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
if (i.equals(lastNumber.get())) {
encodeIntoResponse(resp, lastFactors.get());
} else {
BigInteger[] factors = factor(i);
lastNumber.set(i);
lastFactors.set(factors);
encodeIntoResponse(resp, factors);
}
}
}

在上述例子中,虽然两个变量都是线程安全的,但是在service方法中依然存在竞态条件,因为在上述例子中,类的不变性条件已经被破坏了,只有确保了这个不变性条件不被破坏,才是正确的。当不变性条件中涉及到了多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。因此,当更新某一个变量时,需要在同一个原子操作中对其他变量同时进行更新。

在上述例子中,虽然set方法是原子操作,但是在set方法无法同时更新lastNumber和lastFactors。如果当一个线程执行了lastNumber.set()方法还没执行下一个set方法时,如果此时有一个线程访问service方法,那么得到的结果就与我们所预期的不一致了。

所以,要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

三、加锁机制

3.1内置锁

在Java中提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包含两部分:一个是作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字synchronized来修饰的方法是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象(this).静态的synchronized方法以Class对象为作为锁。

每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或是监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。

Java的内置锁相当于一种互斥锁,最多只有一个线程能持有这种锁。当线程A尝试获取线程B持有的锁时,线程A必须等待或阻塞,知道线程B释放了该锁。如果线程B不释放锁,则线程A也将永远等下去。任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块。

下面时应用了内置锁的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码public class SynchronizedFactorizer implements Servlet {
private BigInteger lastNumber;

private BigInteger[] lastFactors;

public synchronized void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
if (i.equals(lastNumber)) {
encodeIntoResponse(resp, lastFactors.get());
} else {
BigInteger[] factors = factor(i);
lastNumber = i;
lastFactors = factors;
encodeIntoResponse(resp, factors);
}
}
}

虽然使用synchrnoized关键字保证了结果的正确性,但是在同一时刻只有一个线程可以执行service方法,这就导致了服务的响应性非常低,并发性非常的糟糕,变成了一个性能问题,而不是线程安全问题。

3.2 重入

当某个线程请求一个由其他线程持有的锁是,发出请求的线程就会被阻塞,但是,由于内置锁是可重入的,即如果某个线程试图获得一个已经由它自己持有的锁时,那么这个请求就会成功。”重入”意味着获取锁的操作粒度是“线程”而不是“调用”。重入的一种实现方法就是,为每一个锁关联一个计数值和一个所有者线程。当计数值为0时,就认为这个锁是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数值会相应的递减。当计数值为0时,这个锁将被释放。

下面是一个重入的例子:

1
2
3
4
5
6
7
8
9
10
11
复制代码public class Widget{
public synchronized void doSomething(){
System.out.println(toString() + ": calling doSomething");
}
}
public class LoggingWidget extends Widget{
public synchronzied void doSomething(){
System.out.println(toString() + ": calling doSomething");
super.doSomething();
}
}

在上述例子中,LoggingWidget继承了Widget并改写了父类,并且都是用了synchronized关键字修饰doSomething方法,如果子类对象在调用doSomething方法时。如果没有可重入锁,那么这段代码就会产生死锁。因为每个doSomething方法在执行前都会获得Widget上的锁,如果内置锁是不可重入的,那么在调用super.doSomething时就无法获得Widget上的锁,因为这个锁已经被持有了,从而线程将永远停顿下去,等待一个永远也无法获得的锁。注意:在这里synchronized关键字修饰的是方法体,也就是说它锁住的是对象本身(this),所以当第一次进入doSomething方法时,锁住的是LoggingWidget对象,而在调用super.doSomething时,并没有新建一个父类对象,锁的对象还是this.

四、用锁来保护状态

对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。对象的内置锁与其状态之间没有内在的关联,对象的域并不一定要通过内置锁类保护。当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁,每个对象都有一个内置锁。

每个共享的和可变的变量都应该只由一个锁来保护。一种常见的加锁约定是,将所有可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路劲进行同步,使得在该对象上不会发生并发访问。但是,如果在添加新的方法或代码路径时忘记了使用同步,那么这种加锁协议会很容易被破坏。

我们应该知道的是,并非所有数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。当某个变量由锁来保护时,意味着每次访问这个变量时都需要首先获得这个锁,这样就确保在同一时刻只有一个一个线程可以访问这个变量。当类的不变性条件涉及多个状态变量时,那么还有另外一个需求:在不变性条件中的每个变量都必须由同一个锁来保护。

虽然同步可以避免竞态条件问题,但并不意味着可以在每个方法声明时都是用关键字synchronized.如果将程序中存在过多的同步方法,可能会导致活跃性问题或性能问题。

我们应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,确保同步代码块中尽量只存在原子性的操作。

在使用锁时,应该清楚代码块中实现的功能,以及在执行该代码块时是否需要很长的时间,当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络I/O或控制台I/O),一定不要持有锁!!!

更多精彩文章请关注微信公众号:java程序员聚集地

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

优化包大小-PNG部分

发表于 2019-12-04

背景

PNG 图片相对于 JPEG 图片来说,它是一种无损的图像存储格式,同时多了一条透明度通道,所以一般情况下,PNG 图片要比 JPEG 图片要大,并且 PNG 图片往往还是 APK 图片资源中的大头,所以优化 PNG 图片的大小,对于减小包的体积来说,是比较有回报的事情。

关于 PNG 的 wiki:

便携式网络图形(英语:Portable Network Graphics,PNG)是一种无损压缩的位图图形格式,支持索引、灰度、RGB三种颜色方案以及Alpha通道等特性。

关于 JPEG 的 wiki:

联合图像专家小组(英语:Joint Photographic Experts Group,缩写:JPEG)是一种针对照片影像而广泛使用的有损压缩标准方法。

常用的压缩算法

关于 PNG 的压缩算法有很多,这里我们只说两种比较常用的:Indexed_color 和 Color_quantization。这两种也是 Google 在 Android 开发者网站上推荐的,具体可以看 network-xfer。

下面我们会简单说下这两种算法的大概原理,更深入的知识请移步 Google 或者 Wiki。

Indexed_color

字面意思就是索引颜色,通过将具体的 ARGB 颜色存储转换成索引下表,来减少文件的大小。我们知道 ARGB 中,每个通道的存储都需要 8 位,也就是 1 字节,一个 ARGB 存储就需要 4 字节,而索引的存储只需要 1 字节。而索引指向的颜色会存放在一个叫 palette(调色板)的数组里面。

wiki 定义:

在计算中,索引颜色是一种以有限的方式管理数字图像颜色的技术,以节省计算机内存和文件存储空间,同时加快显示刷新和文件传输的速度。它是矢量量化压缩的一种形式。

图片来自于 wiki:

Indexed_color

这种算法很好,但也有缺点,调色板的大小通常只支持 4,16,256 这几种,也就是说最大不会超过 256 个,所以能应用这种算法的 PNG 图片中的颜色使用不能超过 256 个。

Color_quantization

字面意思就是颜色矢量化,通过使用相似颜色来减少图像中使用的颜色种类,再配合调色板,来达到减少图片文件大小的目的,这是一种有损的压缩算法。

图片来自于 wiki:

这是使用标准的 24 位 RGB 颜色的图像:

24位RGB

这是优化成只使用 16 种颜色的图像:

16种颜色

这种算法的缺点就是质量有损,所以如何在质量和大小中间达到一个平衡点,这个至关重要。

谈谈 AAPT

AAPT 是 Android 的资源打包工具,我们常用的 R 文件就是用它生存的,除此之外,它还有压缩 PNG 图片的功能。AAPT 现在有 AAPT 和 AAPT2 了,默认我们都是说的是 AAPT2。

关于 AAPT2 对于 PNG 图片的压缩知识,可以看下 Colt McAnlis 的这篇文章,Smaller PNGs, and Android’s AAPT tool

PS:作者就是 Android 性能宝典里面那个光头。。。

AAPT2 对于 PNG 图片的压缩可以分为三个方面:

  • RGB 是否可以转化成灰度
  • 透明通道是否可以删除
  • 是不是最多只有 256 色(Indexed_color 优化)

接下来,我们从源码入手,只看看上面所说的这几点吧。

源码分析用的是 Android 6.0 也就是 marshmallow 的版本:

android.googlesource.com/platform/fr…

对于 PNG 的分析代码位于 analyze_image() 这个方法中,国际惯例,删除一些不影响分析的代码。

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
复制代码static void analyze_image() {
int w = imageInfo.width;
int h = imageInfo.height;
uint32_t colors[256], col;
int num_colors = 0;
bool isOpaque = true;
bool isPalette = true;
bool isGrayscale = true;
// Scan the entire image and determine if:
// 1. Every pixel has R == G == B (grayscale)
// 2. Every pixel has A == 255 (opaque)
// 3. There are no more than 256 distinct RGBA colors
for (j = 0; j < h; j++) {
const png_byte* row = imageInfo.rows[j];
png_bytep out = outRows[j];
for (i = 0; i < w; i++) {
rr = *row++;
gg = *row++;
bb = *row++;
aa = *row++;
int odev = maxGrayDeviation;
maxGrayDeviation = MAX(ABS(rr - gg), maxGrayDeviation);
maxGrayDeviation = MAX(ABS(gg - bb), maxGrayDeviation);
maxGrayDeviation = MAX(ABS(bb - rr), maxGrayDeviation);

// Check if image is really grayscale
if (isGrayscale) {
if (rr != gg || rr != bb) {
// ==>> Code 1
isGrayscale = false;
}
}
// Check if image is really opaque
if (isOpaque) {
if (aa != 0xff) {
// ==>> Code 2
isOpaque = false;
}
}
// Check if image is really <= 256 colors
if (isPalette) {
col = (uint32_t) ((rr << 24) | (gg << 16) | (bb << 8) | aa);
bool match = false;
for (idx = 0; idx < num_colors; idx++) {
if (colors[idx] == col) {
match = true;
break;
}
}
if (!match) {
if (num_colors == 256) {
// ==>> Code 3
isPalette = false;
} else {
colors[num_colors++] = col;
}
}
}
}
}
*paletteEntries = 0;
*hasTransparency = !isOpaque;
int bpp = isOpaque ? 3 : 4;
int paletteSize = w * h + bpp * num_colors;

// Choose the best color type for the image.
// 1. Opaque gray - use COLOR_TYPE_GRAY at 1 byte/pixel
// 2. Gray + alpha - use COLOR_TYPE_PALETTE if the number of distinct combinations
// is sufficiently small, otherwise use COLOR_TYPE_GRAY_ALPHA
// 3. RGB(A) - use COLOR_TYPE_PALETTE if the number of distinct colors is sufficiently
// small, otherwise use COLOR_TYPE_RGB{_ALPHA}
if (isGrayscale) {
if (isOpaque) {
// ==>> Code 4
*colorType = PNG_COLOR_TYPE_GRAY; // 1 byte/pixel
} else {
// Use a simple heuristic to determine whether using a palette will
// save space versus using gray + alpha for each pixel.
// This doesn't take into account chunk overhead, filtering, LZ
// compression, etc.
if (isPalette && (paletteSize < 2 * w * h)) {
// ==>> Code 5
*colorType = PNG_COLOR_TYPE_PALETTE; // 1 byte/pixel + 4 bytes/color
} else {
// ==>> Code 6
*colorType = PNG_COLOR_TYPE_GRAY_ALPHA; // 2 bytes per pixel
}
}
} else if (isPalette && (paletteSize < bpp * w * h)) {
// ==>> Code 7
*colorType = PNG_COLOR_TYPE_PALETTE;
} else {
if (maxGrayDeviation <= grayscaleTolerance) {
// ==>> Code 8
*colorType = isOpaque ? PNG_COLOR_TYPE_GRAY : PNG_COLOR_TYPE_GRAY_ALPHA;
} else {
// ==>> Code 9
*colorType = isOpaque ? PNG_COLOR_TYPE_RGB : PNG_COLOR_TYPE_RGB_ALPHA;
}
}

}

首先定义了 3 个变量分别表示:isOpaque(是否不透明),isPalette(是否支持调色板),isGrayscale(是否可转化为灰度)。

首先看 Code 1 处的代码:

1
2
3
4
复制代码                if (rr != gg || rr != bb) {
// ==>> Code 1
isGrayscale = false;
}

只有 RGB 三种通道的颜色都一样,才能转化成灰度。

Code 2 处的代码是判断透明通道是否为 0,也就是是不是不透明:

1
2
3
4
复制代码                if (aa != 0xff) {
// ==>> Code 2
isOpaque = false;
}

而 Code 3 处则是判断是否可以用 256 色的调色板:

1
2
3
4
5
6
7
8
复制代码                if (!match) {
if (num_colors == 256) {
// ==>> Code 3
isPalette = false;
} else {
colors[num_colors++] = col;
}
}

colors 是一个数组,里面存放的是图片中已经出现的(不重复)颜色,当颜色的数量大于 256 即表示不支持调色板模式。

然后根据这些条件,来判断使用哪种存储模式,AAPT 中支持的存储模式有以下几种:

  • PNG_COLOR_TYPE_PALETTE

使用调色板模式,最终图片的大小就是 一个像素 1 字节 + 调色板中一个颜色 4 字节

  • PNG_COLOR_TYPE_GRAY

灰度模式,这种是最节省的模式,一个像素 1 字节

  • PNG_COLOR_TYPE_GRAY_ALPHA

灰度模式,同时存在透明通道,一个像素 2 字节

  • PNG_COLOR_TYPE_RGB

RGB 模式,删除了透明通道,一个像素 3 字节

  • PNG_COLOR_TYPE_RGB_ALPHA

ARGB 模式,一个像素 4 字节

PNG_COLOR_TYPE_PALETTE

要使用这种模式,需要满足以下两个条件,分别是 Code 5 和 Code 7:

Code 5

1
2
3
4
5
6
7
8
9
复制代码 if (isGrayscale) {
if (isOpaque) {
} else {
if (isPalette && (paletteSize < 2 * w * h)) {
// ==>> Code 5
*colorType = PNG_COLOR_TYPE_PALETTE; // 1 byte/pixel + 4 bytes/color
}
}
}

在支持灰度模式的前提下,有透明通道,支持调色板模式,同时调色板的长度小于 2 * w * h。

Code 7

1
2
3
4
5
6
7
8
复制代码if (isGrayscale) {

} else {
if (isPalette && (paletteSize < bpp * w * h)) {
// Code ==>> 7
*colorType = PNG_COLOR_TYPE_PALETTE;
}
}

如果不支持灰度模式,但支持调色板,同时调色板长度小于 bpp * w * h,其中 bpp 的大小根据是否为不透明为决定:

1
复制代码    int bpp = isOpaque ? 3 : 4;

PNG_COLOR_TYPE_GRAY

要使用这种模式,需要满足支持灰度模式,同时不透明。代码位于 Code 4:

1
2
3
4
5
6
复制代码 if (isGrayscale) {
if (isOpaque) {
// ==>> Code 4
*colorType = PNG_COLOR_TYPE_GRAY; // 1 byte/pixel
}
}

###PNG_COLOR_TYPE_GRAY_ALPHA

灰度,同时存在透明通道的模式。代码位于 Code 6 和 Code 8:

Code 6

1
2
3
4
5
6
7
8
9
10
复制代码 if (isGrayscale) {
if (isOpaque) {
} else {
if (isPalette && (paletteSize < 2 * w * h)) {
} else {
// ==>> Code 6
*colorType = PNG_COLOR_TYPE_GRAY_ALPHA; // 2 bytes per pixel
}
}
}

Code 8

1
2
3
4
5
6
7
8
9
10
复制代码if (isGrayscale) {

} else if (isPalette && (paletteSize < bpp * w * h)) {
} else {
if (maxGrayDeviation <= grayscaleTolerance) {
// ==>> Code 8
*colorType = isOpaque ? PNG_COLOR_TYPE_GRAY : PNG_COLOR_TYPE_GRAY_ALPHA;
} else {
}
}

maxGrayDeviation 是计算 RGB 通道直接的差值,如果小于 grayscaleTolerance 这个阙值,那么也可以转成灰度。

PNG_COLOR_TYPE_RGB

不透明图片可以删除透明通道,代码位于 Code 9:

1
2
3
4
5
6
7
8
9
10
复制代码if (isGrayscale) {

} else if (isPalette && (paletteSize < bpp * w * h)) {
} else {
if (maxGrayDeviation <= grayscaleTolerance) {
} else {
// ==>> Code 9
*colorType = isOpaque ? PNG_COLOR_TYPE_RGB : PNG_COLOR_TYPE_RGB_ALPHA;
}
}

PNG_COLOR_TYPE_RGB_ALPHA

这个没什么好说的,最后的兜底模式。

小结

AAPT 对 PNG 的优化,主要是 Indexed_color 算法,这也是一种保守的选择,因为这种是无损的,如果我们想要更高的压缩率,可以使用一些其他的压缩工具,来集成到我们的编译打包流程中。

PNG 压缩工具对比

首先,在我们选择其他 PNG 压缩工具其他,我们需要先禁用 AAPT 的默认压缩模式,因为对于 PNG 的压缩,并不是 1 + 1 > 2,可以通过以下代码关闭:

1
2
3
4
5
复制代码android {
aaptOptions {
cruncherEnabled = false
}
}

现在常用的 PNG 压缩工具有 pngcrush、pngquant、zopfli 、tinypng 等,这里我们就先不考虑 tinypng,因为这个只提供了 HTTP 接口的形式,并且有次数限制。

使用 Gradle 集成

为了将 PNG 压缩工具集成到 APK 的构建编译流程,我们使用 Gradle 来实现。作者当前使用 Android Gradle 插件版本为 3.5.0,在这个版本我们可以通过 ApplicationVariant.allRawAndroidResources 获取所有的资源目录。

需要注意的是,我们这个 Task 需要在 MergeResources 这个 Task 之前执行,这样我们可以将压缩后的同名覆盖,再合并到 APK 文件中。

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
复制代码afterEvaluate {

applicationVariants.all { ApplicationVariant variant ->

if (variant.buildType.name != "release") {
return
}


def compressPngTask = task('compressPng')
compressPngTask.doLast {
List<File> allResource = []

variant.allRawAndroidResources.files.forEach { dir ->

if (!dir.exists()) {
return
}

if (dir.isFile()) {
if (dir.name.endsWith(".png")) {
allResource.add(file)
}
} else {
dir.traverse { file ->
if (file.name.endsWith(".png")) {
allResource.add(file)
}
}
}


}


allResource.forEach { file ->
// kb
def oldSize = file.size() / 1024f

println "path = ${file.path}"
try {
// TODO 这里就是我们执行压缩的逻辑
} catch (Throwable ignore) {
println "file: ${file.name} error: ${ignore.message}"
}


println "${file.name}: $oldSize KB ==> ${file.size() / 1024f} KB"

}


}


Task mergeResourcesTask = variant.mergeResourcesProvider.get()
mergeResourcesTask.dependsOn(compressPngTask)
}


}

首先我们创建一个名为 compressPng 的 Task,这个 Task 的任务就是收集所有 PNG 文件路径,这里我们过滤掉 BuildType 不是 release 的 Variant,最后让 MergeResources Task 依赖于 compressPng Task。

记得先关掉 AAPT 默认的 PNG 压缩。

这里我们先记录下关闭 AAPT 默认 PNG 压缩后的 APK 文件大小,和使用默认 PNG 压缩后的 APK 文件大小:

这里我找的是一个直播的项目,里面有比较多的图片资源文件,过滤了 JPEG 和 .9 图,大概有 1000多张 PNG 图片,只使用一个压缩线程。

压缩工具 APK 大小(MB) 耗时
无 81.9 29s
AAPT 78.9 1m 18s

pngquant

首先测试 pngquant,我们将pngquant 集成进去:

1
2
3
4
复制代码                        exec { ExecSpec spec ->
spec.workingDir(project.file('.'))
spec.commandLine("./pngquant", "--skip-if-larger", "--speed", "1", "--strip", "--nofs", "--force", "--output", file.path, "--", file.path)
}

这里我们用的基本都是默认配置:

  • –skip-if-larger 表示如果压缩后的图片更大了,就跳过
  • –speed 1 表示使用最慢的压缩速度,来换取更好的压缩质量
  • –strip 删除图片的一些元数据
  • –nofs 禁用 Floyd–Steinberg dithering 图像抖动处理
  • –force 覆盖源文件
  • –output 输出文件的路径

先执行 Clean Task,再重新执行打包,最终结果如下:

压缩工具 APK 大小(MB) 耗时
无 81.9 29s
AAPT 78.9 32s
pngquant 73.7 1m 18s

zopflipng

集成进去:

1
2
3
4
5
复制代码                        exec { ExecSpec spec ->
spec.workingDir(new File('/Users/leo/Documents/projects/zopfli'))
spec.commandLine("./zopflipng", "-y", "-m", file.path, file.path)

}

使用的也是基本配置:

  • -y 覆盖原文件,不需要询问
  • -m 尽可能多压缩,依赖于文件的大小

先执行 Clean Task,再重新执行打包,最终结果如下:

压缩工具 APK 大小(MB) 耗时
无 81.9 29s
AAPT 78.9 32s
pngquant 73.7 1m 18s
zopflipng 78 36m 17s

pngcursh

集成进去:

1
2
3
4
复制代码                        exec { ExecSpec spec ->
spec.workingDir(new File('/Users/leo/Documents/projects/pngcrush-1.8.13'))
spec.commandLine("./pngcrush", "-ow","-reduce", file.path)
}

使用基本配置:

  • -ow 表示覆盖原文件
  • -reduce 减少无损颜色值的类型和位深度
  • -brute 尝试 176 种压缩方法

先执行 Clean Task,再重新执行打包,最终结果如下:

压缩工具 APK 大小(MB) 耗时
无 81.9 29s
AAPT 78.9 32s
pngquant 73.7 1m 18s
zopflipng 78 36m 17s
pngcursh 78.7 13m 56s

小结

虽然从结果上来看,pngquant 是最好的选择,但因为 pngcursh 这块使用的只是默认的压缩配置,而且 pngcursh 提供的参数是最多的,所以具体哪个更优,只能靠调参数了,众所周知,调参数也是技术活。

总结

推荐使用 WebP 和 SVG 来代替 PNG 和 JPEG 图片的使用,但有些三方库的图片是我们没办法控制的,这块就可以用 PNG 压缩工具来进行优化。至于如果平衡压缩率、压缩耗时,这就是要靠大家去调参数了。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

在Docker容器中部署整套基于SpringCloud的微服

发表于 2019-12-04

摘要

本文以mall-swarm项目为例,主要介绍一个微服务架构的电商项目如何在Docker容器下部署,涉及到大量系统组件的部署及多个Spring Cloud 微服务应用的部署,基于CentOS7.6。

环境搭建

基础环境部署

mall-swarm运行需要的系统组件如下,Docker容器中安装这些组件的方法直接参考该文章即可:mall在Linux环境下的部署(基于Docker容器) 。

组件 版本号
JDK 1.8
Mysql 5.7
Redis 3.2
Elasticsearch 6.4.0
MongoDb 3.2
RabbitMq 3.7.15
Nginx 1.10

镜像打包上传

一共8个应用服务需要打包成Docker镜像,具体如何打包可以参考使用Maven插件构建Docker镜像 。
需要注意的是如果打包过程中遇到找不到mall-common、mall-mbg或mall-security的情况,需要先按顺序将这些模块install到本地maven仓库再进行打包。

应用 版本号
mall-registry 1.8
mall-config 5.7
mall-monitor 3.2
mall-gateway 6.4.0
mall-admin 3.2
mall-portal 3.7.15
mall-search 1.10
mall-demo 1.10

镜像打包上传完成后,完整docker仓库镜像示意图:

应用部署

部署mall-registry

  • 通过以下命令运行注册中心mall-registry:
1
2
3
4
复制代码docker run -p 8001:8001 --name mall-registry \
-v /etc/localtime:/etc/localtime \
-v /mydata/app/mall-registry/logs:/var/logs \
-d mall/mall-registry:1.0-SNAPSHOT
  • 运行成功后,通过访问该地址可以查看注册中心控制台:http://192.168.6.132:8001/

部署mall-config

  • 通过以下命令运行配置中心mall-config:
1
2
3
4
5
复制代码docker run -p 8301:8301 --name mall-config \
--link mall-registry:mall-registry \
-v /etc/localtime:/etc/localtime \
-v /mydata/app/mall-config/logs:/var/logs \
-d mall/mall-config:1.0-SNAPSHOT
  • 运行成功后,通过访问该地址可以查看mall-admin在prod环境下的配置信息:http://192.168.6.132:8301/master/admin-prod.yml
  • 需要注意的是prod环境下从配置中心获取的是存储在git仓库中的配置,如需更改需要将mall-config模块的配置文件application.yml中的git仓库配置改为你自己的。
1
2
3
4
5
6
7
8
9
10
复制代码spring:
cloud:
config:
server:
git: #Git仓库存储
uri: https://gitee.com/macrozheng/mall-config.git #改为你自己的配置
username: macro
password: 123456
clone-on-start: true
search-paths: '{application}'

部署mall-monitor

  • 通过以下命令运行监控中心mall-monitor:
1
2
3
4
5
复制代码docker run -p 8101:8101 --name mall-monitor \
--link mall-registry:mall-registry \
-v /etc/localtime:/etc/localtime \
-v /mydata/app/mall-monitor/logs:/var/logs \
-d mall/mall-monitor:1.0-SNAPSHOT
  • 运行完成后可以通过该地址查看监控中心信息,账号密码为macro:123456:http://192.168.6.132:8101

部署mall-gateway

  • 通过以下命令运行网关服务mall-gateway:
1
2
3
4
5
复制代码docker run -p 8201:8201 --name mall-gateway \
--link mall-registry:mall-registry \
-v /etc/localtime:/etc/localtime \
-v /mydata/app/mall-gateway/logs:/var/logs \
-d mall/mall-gateway:1.0-SNAPSHOT
  • 运行完成后可以通过该地址查看动态路由规则:http://192.168.6.132:8201/actuator/gateway/routes

部署mall-admin

  • 通过以下命令运行后台服务mall-admin:
1
2
3
4
5
6
复制代码docker run -p 8180:8180 --name mall-admin \
--link mysql:db \
--link mall-registry:mall-registry \
-v /etc/localtime:/etc/localtime \
-v /mydata/app/mall-admin/logs:/var/logs \
-d mall/mall-admin:1.0-SNAPSHOT
  • 通过mall-gateway网关服务访问接口文档:http://192.168.6.132:8201/mall-admin/swagger-ui.html

部署mall-portal

  • 通过以下命令运行前台服务mall-portal:
1
2
3
4
5
6
7
8
9
复制代码docker run -p 8085:8085 --name mall-portal \
--link mysql:db \
--link redis:redis \
--link mongo:mongo \
--link rabbitmq:rabbit \
--link mall-registry:mall-registry \
-v /etc/localtime:/etc/localtime \
-v /mydata/app/mall-portal/logs:/var/logs \
-d mall/mall-portal:1.0-SNAPSHOT
  • 通过mall-gateway网关服务访问接口文档:http://192.168.6.132:8201/mall-portal/swagger-ui.html

部署mall-search

  • 通过以下命令运行搜索服务mall-search:
1
2
3
4
5
6
7
复制代码docker run -p 8081:8081 --name mall-search \
--link mysql:db \
--link elasticsearch:es \
--link mall-registry:mall-registry \
-v /etc/localtime:/etc/localtime \
-v /mydata/app/mall-search/logs:/var/logs \
-d mall/mall-search:1.0-SNAPSHOT
  • 通过mall-gateway网关服务访问接口文档:http://192.168.6.132:8201/mall-search/swagger-ui.html

部署mall-demo

  • 通过以下命令运行测试服务mall-demo:
1
2
3
4
5
6
复制代码docker run -p 8082:8082 --name mall-demo \
--link mysql:db \
--link mall-registry:mall-registry \
-v /etc/localtime:/etc/localtime \
-v /mydata/app/mall-demo/logs:/var/logs \
-d mall/mall-demo:1.0-SNAPSHOT
  • 通过mall-gateway网关服务访问接口文档:http://192.168.6.132:8201/mall-demo/swagger-ui.html

运行完成效果展示

  • 注册中心控制台信息:

  • 监控中心应用信息:

可视化管理工具

Portainer 是一款轻量级的应用,它提供了图形化界面,用于方便的管理Docker环境,包括单机环境和集群环境,下面我们将用Portainer来管理Docker容器中的应用。

  • 官网地址:github.com/portainer/p…
  • 获取Docker镜像文件:
1
复制代码docker pull portainer/portainer
  • 使用docker容器运行Portainer:
1
2
3
4
5
复制代码docker run -p 9000:9000 -p 8000:8000 --name portainer \
--restart=always \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /mydata/portainer/data:/data \
-d portainer/portainer
  • 查看Portainer的DashBoard信息:

  • 查看所有运行中的容器信息:

  • 查看所有已经下载的Docker镜像:

  • 查看mall-portal应用的统计信息:

  • 查看mall-portal应用的运行过程中打印的日志信息:

  • 进入mall-portal应用的容器内部来操作容器内部系统:

项目地址

github.com/macrozheng/…

公众号

mall项目全套学习教程连载中,关注公众号第一时间获取。

公众号图片

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

13行代码实现:Python实时视频采集(附源码) 实时:视

发表于 2019-12-03

13行代码实现:Python实时视频采集(附源码)
一、前言

本文是《人脸识别完整项目实战》系列博文第3部分:程序设计篇(Python版),第1节《Python实时视频采集程序设计》,本章内容系统介绍:基于Python+opencv如何实现实时视频采集。

完整的相关内容已录制成视频课程,点击跳转:《人脸识别完整项目实战(附源码)》

整个《人脸识别完整项目实战》系统架构结构如下图所示:

项目概述篇:系统介绍人脸识别项目的系统架构设计、项目关键技术说明、项目业务需求分析、项目业务流程设计;

环境部署篇:提供C++和Python两种编程语言的版本,系统介绍项目开发环境概述、DLib框架源码编译、项目工程文件创建、项目开发环境配置、项目性能优化设置;

程序设计篇:从实时视频采集开始,涵盖人脸区域检测、人脸特征点标定、人脸对齐、人脸比对和活体检测等全部技术环节的代码设计、运行演示和执行结果输出;

模型训练篇:基于人脸识别区域检测和人俩识别特征点标定两个应用场景,介绍数据样本采集、算法模型训练和算法模型测试的过程,让大家都人脸识别有一个完整的直观的认识;

算法原理篇:基于人脸识别区域检测和人俩识别特征点标定两个应用场景,人脸区域检测和人脸特征点标定的算法原理和实现机制,让大家对人脸识别与机器学习、深度学习进行有效关联;

学习框架篇:系统介绍主流深度学习框架,重点就本课程用到Dlib深度学习框架进行介绍,通过dlib深度学习实战案例1和dlib深度学习实战案例2,两个完整的案例,让大家对dlib的深度学习框架有一个直观的认识;

二、正文

2.1 程序逻辑

1
复制代码   Python实时视频采集程序主要流程共分为10个步骤,具体如下图所示:

流程描述:

库文件导入:导入程序依赖的python安装包;

摄像头管理对象创建和初始化:是对opencv VideoCapture对象的创建和初始化,通过它打开摄像头设备;

启动循环监控:循环处理每一帧图片;

图像抓拍:利用opencv提供的摄像头管理设备,进行逐帧图像内容的抓取,然后进行处理;

图像窗口显示:利用opencv的窗口对象,进行抓拍内容的显示。

等待用户输入:利用opencv提供的键盘输入监控程序,获取用户指令。

摄像头释放:收到退出指令后,释放摄像头管理设备资源。

2.2 接口说明

1
复制代码  python实时视频监控采集功能的实现,主要是采用了opencv开源框架提供的摄像头管理类:VideoCapture。该类的主要方法和属性如下图所示:

opencv的摄像头管理类,我们主要应用了其open(打开摄像头)、read(读取每一帧)、release(释放设备)等函数功能能。

2.3 源码设计

源码的执行,需要导入opencv库文件,直接执行:pip install opencv 即可实现。具体程序代码如下图所示:

实时:视频图像采集(opencv)

import cv2
cap = cv2.VideoCapture(0)

从视频流循环帧

while True:

1
2
3
4
5
6
复制代码ret, frame = cap.read()
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
cv2.imshow("Frame", frame)
# 退出:Q
if cv2.waitKey(1) & 0xFF == ord('q'):
break

清理窗口

cv2.destroyAllWindows()
2.4 运行效果

​三、未完待续

本文是《人脸识别完整项目实战》系列博文第3部分:程序设计篇(Python版)第一节《实时视频采集程序设计(python)》,全文共53个章节,持续更新,敬请关注。人脸识别技术交流QQ群:887934385 。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

关于HashMap容量的初始化,还有这么多学问。

发表于 2019-12-03

在《HashMap中傻傻分不清楚的那些概念》文章中,我们介绍了HashMap中和容量相关的几个概念,简单介绍了一下HashMap的扩容机制。

文中我们提到,默认情况下HashMap的容量是16,但是,如果用户通过构造函数指定了一个数字作为容量,那么Hash会选择大于该数字的第一个2的幂作为容量。(3->4、7->8、9->16)

本文,延续上一篇文章,我们再来深入学习下,到底应不应该设置HashMap的默认容量?如果真的要设置HashMap的初始容量,我们应该设置多少?

为什么要设置HashMap的初始化容量

我们之前提到过,《阿里巴巴Java开发手册》中建议我们设置HashMap的初始化容量。

hash
那么,为什么要这么建议?你有想过没有。

我们先来写一段代码在JDK 1.7 (jdk1.7.0_79)下面来分别测试下,在不指定初始化容量和指定初始化容量的情况下性能情况如何。(jdk 8 结果会有所不同,我会在后面的文章中分析)

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
复制代码public static void main(String[] args) {
int aHundredMillion = 10000000;

Map<Integer, Integer> map = new HashMap<>();

long s1 = System.currentTimeMillis();
for (int i = 0; i < aHundredMillion; i++) {
map.put(i, i);
}
long s2 = System.currentTimeMillis();

System.out.println("未初始化容量,耗时 : " + (s2 - s1));


Map<Integer, Integer> map1 = new HashMap<>(aHundredMillion / 2);

long s5 = System.currentTimeMillis();
for (int i = 0; i < aHundredMillion; i++) {
map1.put(i, i);
}
long s6 = System.currentTimeMillis();

System.out.println("初始化容量5000000,耗时 : " + (s6 - s5));


Map<Integer, Integer> map2 = new HashMap<>(aHundredMillion);

long s3 = System.currentTimeMillis();
for (int i = 0; i < aHundredMillion; i++) {
map2.put(i, i);
}
long s4 = System.currentTimeMillis();

System.out.println("初始化容量为10000000,耗时 : " + (s4 - s3));
}

以上代码不难理解,我们创建了3个HashMap,分别使用默认的容量(16)、使用元素个数的一半(5千万)作为初始容量、使用元素个数(一亿)作为初始容量进行初始化。然后分别向其中put一亿个KV。

输出结果:

1
2
3
复制代码未初始化容量,耗时 : 14419
初始化容量5000000,耗时 : 11916
初始化容量为10000000,耗时 : 7984

从结果中,我们可以知道,在已知HashMap中将要存放的KV个数的时候,设置一个合理的初始化容量可以有效的提高性能。

当然,以上结论也是有理论支撑的。我们上一篇文章介绍过,HashMap有扩容机制,就是当达到扩容条件时会进行扩容。HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。在HashMap中,threshold = loadFactor * capacity。

所以,如果我们没有设置初始容量大小,随着元素的不断增加,HashMap会发生多次扩容,而HashMap中的扩容机制决定了每次扩容都需要重建hash表,是非常影响性能的。(关于resize,后面我会有文章单独介绍)

从上面的代码示例中,我们还发现,同样是设置初始化容量,设置的数值不同也会影响性能,那么当我们已知HashMap中即将存放的KV个数的时候,容量设置成多少为好呢?

HashMap中容量的初始化

在上一篇文章中,我们通过代码实例其实介绍过,默认情况下,当我们设置HashMap的初始化容量时,实际上HashMap会采用第一个大于该数值的2的幂作为初始化容量。

上一篇文章中有个例子

1
2
3
4
5
6
7
复制代码Map<String, String> map = new HashMap<String, String>(1);
map.put("hahaha", "hollischuang");

Class<?> mapType = map.getClass();
Method capacity = mapType.getDeclaredMethod("capacity");
capacity.setAccessible(true);
System.out.println("capacity : " + capacity.invoke(map));

初始化容量设置成1的时候,输出结果是2。在jdk1.8中,如果我们传入的初始化容量为1,实际上设置的结果也为1,上面代码输出结果为2的原因是代码中map.put(“hahaha”, “hollischuang”);导致了扩容,容量从1扩容到2。

那么,话题再说回来,当我们通过HashMap(int initialCapacity)设置初始容量的时候,HashMap并不一定会直接采用我们传入的数值,而是经过计算,得到一个新值,目的是提高hash的效率。(1->1、3->4、7->8、9->16)

在Jdk 1.7和Jdk 1.8中,HashMap初始化这个容量的时机不同。jdk1.8中,在调用HashMap的构造函数定义HashMap的时候,就会进行容量的设定。而在Jdk 1.7中,要等到第一次put操作时才进行这一操作。

不管是Jdk 1.7还是Jdk 1.8,计算初始化容量的算法其实是如出一辙的,主要代码如下:

1
2
3
4
5
6
7
复制代码    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;

上面的代码挺有意思的,一个简单的容量初始化,Java的工程师也有很多考虑在里面。

上面的算法目的挺简单,就是:根据用户传入的容量值(代码中的cap),通过计算,得到第一个比他大的2的幂并返回。

聪明的读者们,如果让你设计这个算法你准备如何计算?如果你想到二进制的话,那就很简单了。举几个例子看一下:

QQ20180527-173743
请关注上面的几个例子中,蓝色字体部分的变化情况,或许你会发现些规律。5->8、9->16、19->32、37->64都是主要经过了两个阶段。

Step 1,5->7

Step 2,7->8

Step 1,9->15

Step 2,15->16

Step 1,19->31

Step 2,31->32

对应到以上代码中,Step1:

1
2
3
4
5
复制代码n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;

对应到以上代码中,Step2:

1
复制代码return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;

Step 2 比较简单,就是做一下极限值的判断,然后把Step 1得到的数值+1。

Step 1 怎么理解呢?**其实是对一个二进制数依次向右移位,然后与原值取或。**其目的对于一个数字的二进制,从第一个不为0的位开始,把后面的所有位都设置成1。

随便拿一个二进制数,套一遍上面的公式就发现其目的了:

1
2
3
4
5
6
复制代码1100 1100 1100 >>>1 = 0110 0110 0110
1100 1100 1100 | 0110 0110 0110 = 1110 1110 1110
1110 1110 1110 >>>2 = 0011 1011 1011
1110 1110 1110 | 0011 1011 1011 = 1111 1111 1111
1111 1111 1111 >>>4 = 1111 1111 1111
1111 1111 1111 | 1111 1111 1111 = 1111 1111 1111

通过几次无符号右移和按位或运算,我们把1100 1100 1100转换成了1111 1111 1111 ,再把1111 1111 1111加1,就得到了1 0000 0000 0000,这就是大于1100 1100 1100的第一个2的幂。

好了,我们现在解释清楚了Step 1和Step 2的代码。就是可以把一个数转化成第一个比他自身大的2的幂。(可以开始佩服Java的工程师们了,使用无符号右移和按位或运算大大提升了效率。)

但是还有一种特殊情况套用以上公式不行,这些数字就是2的幂自身。如果数字4 套用公式的话。得到的会是 8 :

1
2
3
4
5
6
7
复制代码Step 1: 
0100 >>>1 = 0010
0100 | 0010 = 0110
0110 >>>1 = 0011
0110 | 0011 = 0111
Step 2:
0111 + 0001 = 1000

为了解决这个问题,JDK的工程师把所有用户传进来的数在进行计算之前先-1,就是源码中的第一行:

1
复制代码int n = cap - 1;

至此,再来回过头看看这个设置初始容量的代码,目的是不是一目了然了:

1
2
3
4
5
6
7
复制代码    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;

HashMap中初始容量的合理值

当我们使用HashMap(int initialCapacity)来初始化容量的时候,jdk会默认帮我们计算一个相对合理的值当做初始容量。那么,是不是我们只需要把已知的HashMap中即将存放的元素个数直接传给initialCapacity就可以了呢?

关于这个值的设置,在《阿里巴巴Java开发手册》有以下建议:

Demo
这个值,并不是阿里巴巴的工程师原创的,在guava(21.0版本)中也使用的是这个值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码public static <K, V> HashMap<K, V> newHashMapWithExpectedSize(int expectedSize) {
return new HashMap<K, V>(capacity(expectedSize));
}

/**
* Returns a capacity that is sufficient to keep the map from being resized as long as it grows no
* larger than expectedSize and the load factor is ≥ its default (0.75).
*/
static int capacity(int expectedSize) {
if (expectedSize < 3) {
checkNonnegative(expectedSize, "expectedSize");
return expectedSize + 1;
}
if (expectedSize < Ints.MAX_POWER_OF_TWO) {
// This is the calculation used in JDK8 to resize when a putAll
// happens; it seems to be the most conservative calculation we
// can make. 0.75 is the default load factor.
return (int) ((float) expectedSize / 0.75F + 1.0F);
}
return Integer.MAX_VALUE; // any large value
}

在return (int) ((float) expectedSize / 0.75F + 1.0F);上面有一行注释,说明了这个公式也不是guava原创,参考的是JDK8中putAll方法中的实现的。感兴趣的读者可以去看下putAll方法的实现,也是以上的这个公式。

虽然,当我们使用HashMap(int initialCapacity)来初始化容量的时候,jdk会默认帮我们计算一个相对合理的值当做初始容量。但是这个值并没有参考loadFactor的值。

也就是说,如果我们设置的默认值是7,经过Jdk处理之后,会被设置成8,但是,这个HashMap在元素个数达到 8*0.75 = 6的时候就会进行一次扩容,这明显是我们不希望见到的。

如果我们通过expectedSize / 0.75F + 1.0F计算,7/0.75 + 1 = 10 ,10经过Jdk处理之后,会被设置成16,这就大大的减少了扩容的几率。

当HashMap内部维护的哈希表的容量达到75%时(默认情况下),会触发rehash,而rehash的过程是比较耗费时间的。所以初始化容量要设置成expectedSize/0.75 + 1的话,可以有效的减少冲突也可以减小误差。

所以,我可以认为,当我们明确知道HashMap中元素的个数的时候,把默认容量设置成expectedSize / 0.75F + 1.0F 是一个在性能上相对好的选择,但是,同时也会牺牲些内存。

总结

当我们想要在代码中创建一个HashMap的时候,如果我们已知这个Map中即将存放的元素个数,给HashMap设置初始容量可以在一定程度上提升效率。

但是,JDK并不会直接拿用户传进来的数字当做默认容量,而是会进行一番运算,最终得到一个2的幂。原因在(全网把Map中的hash()分析的最透彻的文章,别无二家。)介绍过,得到这个数字的算法其实是使用了使用无符号右移和按位或运算来提升效率。

但是,为了最大程度的避免扩容带来的性能消耗,我们建议可以把默认容量的数字设置成expectedSize / 0.75F + 1.0F 。在日常开发中,可以使用

1
复制代码Map<String, String> map = Maps.newHashMapWithExpectedSize(10);

来创建一个HashMap,计算的过程guava会帮我们完成。

但是,以上的操作是一种用内存换性能的做法,真正使用的时候,要考虑到内存的影响。

最后,留一个思考题:为什么JDK 8中,putAll方法采用了这个expectedSize / 0.75F + 1.0F公式,而put、构造函数等并没有默认使用这个公式呢?

关于作者:Hollis,一个对Coding有着独特追求的人,某互联网公司技术专家,个人技术博主,技术文章全网阅读量数千万,《程序员的三门课》联合作者。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

微服务分布式事务4种解决方案实战

发表于 2019-12-02

案列源码地址
github.com/qinxuewu/bo…

分布式事务

  • 分布式事务是指事务的参与者,支持事务的服务器,资源服务器分别位于分布式系统的不同节点之上,通常一个分布式事物中会涉及到对多个数据源或业务系统的操作。
  • 典型的分布式事务场景:跨银行转操作就涉及调用两个异地银行服务

CAP理论

  • CAP理论:一个分布式系统不可能同时满足一致性,可用性和分区容错性这个三个基本需求,最多只能同时满足其中两项
  • 一致性(C):数据在多个副本之间是否能够保持一致的特性。
  • 可用性(A):是指系统提供的服务必须一致处于可用状态,对于每一个用户的请求总是在有限的时间内返回结果,超过时间就认为系统是不可用的
  • 分区容错性(P):分布式系统在遇到任何网络分区故障的时候,仍然需要能够保证对外提供满足一致性和可用性的服务,除非整个网络环境都发生故障。

CAP定理的应用

  • 放弃P(CA):如果希望能够避免系统出现分区容错性问题,一种较为简单的做法就是将所有的数据(或者是与事物先相关的数据)都放在一个分布式节点上,这样虽然无法保证100%系统不会出错,但至少不会碰到由于网络分区带来的负面影响
  • 放弃A(CP):其做法是一旦系统遇到网络分区或其他故障时,那受到影响的服务需要等待一定的时间,应用等待期间系统无法对外提供正常的服务,即不可用
  • 放弃C(AP):这里说的放弃一致性,并不是完全不需要数据一致性,是指放弃数据的强一致性,保留数据的最终一致性。

BASE理论

  • BASE是基本可用,软状态,最终一致性。是对CAP中一致性和可用性权限的结果,是基于CAP定理演化而来的,核心思想是即使无法做到强一致性,但每个应用都可以根据自身的业务特定,采用适当的方式来使系统达到最终一致性

2PC提交

  • 二阶段提交协议是将事务的提交过程分成提交事务请求和执行事务提交两个阶段进行处理。

阶段1:提交事务请求

  • 事务询问:协调者向所有的参与者发送事务内容,询问是否可以执行事务提交操作,并开始等待各参与者的响应
  • 执行事务:各参与者节点执行事务操作,并将Undo和Redo信息记入事务日志中
  • 如果参与者成功执事务操作,就反馈给协调者Yes响应,表示事物可以执行,如果没有成功执行事务,就反馈给协调者No响应,表示事务不可以执行
  • 二阶段提交一些的阶段一夜被称为投票阶段,即各参与者投票票表明是否可以继续执行接下去的事务提交操作

阶段二:执行事务提交

  • 假如协调者从所有的参与者或得反馈都是Yes响应,那么就会执行事务提交。
  • 发送提交请求:协调者向所有参与者节点发出Commit请求
  • 事务提交:参与者接受到Commit请求后,会正式执行事务提交操作,并在完成提交之后放弃整个事务执行期间占用的事务资源
  • 反馈事务提交结果:参与者在完成事物提交之后,向协调者发送ACK消息
  • 完成事务:协调者接收到所有参与者反馈的ACK消息后,完成事务

中断事务

  • 假如任何一个参与者向协调者反馈了No响应,或者在等待超市之后,协调者尚无法接收到所有参与者的反馈响应,那么就中断事务。
  • 发送回滚请求:协调者向所有参与者节点发出Rollback请求
  • 事务回滚:参与者接收到Rollback请求后,会利用其在阶段一种记录的Undo信息执行事物回滚操作,并在完成回滚之后释放事务执行期间占用的资源。
  • 反馈事务回滚结果:参与则在完成事务回滚之后,向协调者发送ACK消息
  • 中断事务:协调者接收到所有参与者反馈的ACk消息后,完成事务中断、

优缺点

  • 原理简单,实现方便
  • 缺点是同步阻塞,单点问题,脑裂,保守

3PC提交

  • 三阶段提,也叫三阶段提交协议,是二阶段提交(2PC)的改进版本。
  • 与两阶段提交不同的是,三阶段提交有两个改动点。引入超时机制。同时在协调者和参与者中都引入超时机制。在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。
  • 三阶段提交就有CanCommit、PreCommit、DoCommit三个阶段。

Seata分布式事务方案

  • Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。

Seata术语

  • TC:事务协调者。维护全局和分支事务的状态,驱动全局事务提交或回滚。
  • TM:事务管理器。定义全局事务的范围:开始全局事务、提交或回滚全局事务
  • RM:管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。

Seata的2PC方案

  • 一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。
  • 二阶段:提交异步化,非常快速地完成。回滚通过一阶段的回滚日志进行反向补偿。
  • 一阶段本地事务提交前,需要确保先拿到 全局锁 。拿不到全局锁 ,不能提交本地事务。
  • 拿全局锁的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。
  • 在数据库本地事务隔离级别读已提交或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交
  • 如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。

Seata执行流程分析

seatas执行流程.png

  • 每个RM使用DataSourceProxy链接数据路,目的是使用ConnectionProxy,使用数据源和数据代理的目的是在第一阶段将undo_log和业务数据放在一个本地事务提交,这样就保存了只要有业务操作就一定有undo_log
  • 在第一阶段undo_log中存放了数据修改前后修改后的值,为事务回滚做好准别,所以第一阶段完成就已经将分支事务提交了,也就释放了锁资源
  • TM开启全局事务开始,将XID全局事务ID放在事务上下文中,通过feign调用也将XID传入下游分支事务,每个分支事务将自己的Branch ID 分支事务ID与XID关联
  • 第二阶段全局事务提交,TC会通知各分支参与者提交分支事务,在第一阶段就已经提交了分支事务,这里各参与者只需要删除undo_log即可,并且可以异步执行,第二阶段很快可以完成
  • 如果某一个分支事务异常,第二阶段就全局事务回滚操作,TC会通知各分支参与者回滚分支事务,通过XID和Branch-ID找到对应的回滚日志,通过回滚日志生成的反向SQL并执行,以完成分支事务回滚到之前

Seata的实战案列

  • github.com/seata/seata…
  • github.com/seata/seata…

TCC分布式事务

  • TCC是服务化的两阶段编程模型,其Try、Confirm、Cancel,3个方法均由业务编码实现
  • TCC要求每个分支事务实现三个操作:预处理Try,确认Confirm,撤销Cancel。
  • Try操作做业务检查及资源预留,
  • Confirm做业务确认操作
  • Cancel实现一个与Try相反的操作即回滚操作。
  • TM首先发起所有的分支事务Try操作,任何一个分支事务的Try操作执行失败,TM将会发起所有分支事务的Cancel操作,若Try操作全部成功,TM将会发起所有分支事务的Confirm操作,其中Confirm/Cancel操作若执行失败,TM会进行重试。

TCC的三个阶段

  • Try阶段是做业务检查(一致性)及资源预留(隔离),此阶段仅是一个初步操作,它和后续的Confirmy一起才能构成一个完整的业务逻辑
  • Confirm阶段是做确认提交,Try阶段所有分支事务执行成功后开始执行Confirm,通常情况下,采用TCC则认为Confirm阶段是不会出错的,即:只要Try成功,Confirm一定成功,若Confirm阶段真的出错,需要引入重试机制或人工处理
  • Cancel阶段是在业务执行错误需要回滚到状态下执行分支事务的取消,预留资源的释放,通常情况下,采用TCC则认为Cancel阶段也一定是真功的,若Cance阶段真的出错,需要引入重试机制或人工处理
  • TM事务管理器:TM事务管理器可以实现为独立的服务,也可以让全局事务发起方充当TM的角色,TM独立出来是为了公用组件,是为了考虑系统结构和软件的复用
  • TM在发起全局事务时生成全局事务记录,全局事务ID贯穿整个分布式事务调用链条,用来记录事务上下文,追踪和记录状态,用于Confirm和cacel失败需要进行重试,因此需要实现幂等

TCC的三种异常处理情况

幂等处理

  • 因为网络抖动等原因,分布式事务框架可能会重复调用同一个分布式事务中的一个分支事务的二阶段接口。所以分支事务的二阶段接口Confirm/Cancel需要能够保证幂等性。如果二阶段接口不能保证幂等性,则会产生严重的问题,造成资源的重复使用或者重复释放,进而导致业务故障。
  • 对于幂等类型的问题,通常的手段是引入幂等字段进行防重放攻击。对于分布式事务框架中的幂等问题,同样可以祭出这一利器。
  • 幂等记录的插入时机是参与者的Try方法,此时的分支事务状态会被初始化为INIT。然后当二阶段的Confirm/Cancel执行时会将其状态置为CONFIRMED/ROLLBACKED。
  • 当TC重复调用二阶段接口时,参与者会先获取事务状态控制表的对应记录查看其事务状态。如果状态已经为CONFIRMED/ROLLBACKED,那么表示参与者已经处理完其分内之事,不需要再次执行,可以直接返回幂等成功的结果给TC,帮助其推进分布式事务。

空回滚

  • 当没有调用参与方Try方法的情况下,就调用了二阶段的Cancel方法,Cancel方法需要有办法识别出此时Try有没有执行。如果Try还没执行,表示这个Cancel操作是无效的,即本次Cancel属于空回滚;如果Try已经执行,那么执行的是正常的回滚逻辑。
  • 要应对空回滚的问题,就需要让参与者在二阶段的Cancel方法中有办法识别到一阶段的Try是否已经执行。很显然,可以继续利用事务状态控制表来实现这个功能。
  • 当Try方法被成功执行后,会插入一条记录,标识该分支事务处于INIT状态。所以后续当二阶段的Cancel方法被调用时,可以通过查询控制表的对应记录进行判断。如果记录存在且状态为INIT,就表示一阶段已成功执行,可以正常执行回滚操作,释放预留的资源;如果记录不存在则表示一阶段未执行,本次为空回滚,不释放任何资源。

资源悬挂

  • 问题:TC回滚事务调用二阶段完成空回滚后,一阶段执行成功
  • 解决:事务状态控制记录作为控制手段,二阶段发现无记录时插入记录,一阶段执行时检查记录是否存在

TCC和2PC比较

  • 2PC通常都是在跨库的DB层面,而TCC则在应用层面处理,需要通过业务逻辑实现,这种分布式事务的实现方式优势在于,可以让应用自己定义数据操作的粒度,使得降低锁冲突,提高吞吐量成为可能
  • 而不足之处则在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现Try,confirm,cancel三个操作。此外,其实现难度也比较大,需要按照网络状态,系统故障的不同失败原因实现不同的回滚策略

Hmily框架实现TCC案列

hmily分布式事务.png

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
复制代码# 账户A
try:
try的幂等效验
try的悬挂处理
检查余额是否够30元
扣减30元


confirm:
空处理即可,通常TCC阶段是认为confirm是不会出错的

cancel:
cancel幂等效验
cacel空回滚处理
增加可用余额30元,回滚操作


# 账户B

try:
空处理即可

confirm:
confirm的幂等效验
正式增加30元
cancel:
空处理即可

RocketMQ实现可靠消息最终一致性

  • 可靠消息最终一致性就是保证消息从生产方经过消息中间件传递到消费方的一致性
  • RocketMQ主要解决了两个功能:本地事务与消息发送的原子性问题。事务参与方接收消息的可靠性
  • 可靠消息最终一致性事务适合执行周期长且实时性要求不高的场景,引入消息机制后,同步的事务操作变为基于消息执行的异步操作,避免分布式事务中的同步阻塞操作的影响,并实现了两个服务的解耦

RocketMQ实现可靠消息最终一致性

最大努力通知

最大努力通知与可靠消息一致性有什么不同

  • 可靠消息一致性,发起通知方需要保证将消息发出去,并且将消息发送到接收通知方,消息的可靠性由发起通知方保证
  • 最大努力通知,发起通知方尽最大的努力将业务处理结果通知为接收通知方,但是消息可能接收不到,此时需要接收通知方主动调用发起通知方的接口查询业务,通知可靠性关键在于接收通知方

两者的应用场景

  • 可靠消息一致性关注的是交易过程的事务一致,以异步的方式完成交易
  • 最大努力通知关注的是交易后的通知事务,即将交易结果可靠的通知出去

基于MQ的ack机制实现最大努力通知

最大努力通知方案1

  • 利用MQ的ack机制由MQ向接收通知方发送消息通知,发起方将普通消息发送到MQ
  • 接收通知监听MQ,接收消息,业务处理完成回应ACK
  • 接收通知方如果没有回应ACK则MQ会重复通知,按照时间间隔的方式,逐步拉大通知间隔
  • 此方案适用于内部微服务之间的通知,不适应与通知外部平台

方案二:增加一个通知服务区进行通知,提供外部第三方时适用

最大努力通知方案2

分布式事务方案对比分析

  • 2PC 最大的一个诟病是一个阻塞协议。RM在执行分支事务后需要等待TM的决定,此时服务会阻塞锁定资源。由于其阻塞机制和最差时间复杂度高,因此,这种设计不能适应随着事务涉及的服务数量增加而扩展的需要,很难用于并发较高以及子事务生命周期较长的分布式服务中
  • 如果拿TCC事务的处理流程与2PC两阶段提交做比较,2PC通常都是在跨库的DB层面,而TCC则在应用层面处理,需要通过业务逻辑来实现。这种分布式事务的优势在于,可以让应用自定义数据操作的粒度,使得降低锁冲突,提高吞吐量成为可能。而不足之处在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现三个操作。此外,其实现难度也比较大,需要按照网络状态,系统故障等不同失败原因实现不同的策略。
  • 可靠消息最终一致性事务适合执行周期长且实时性要求不高的场景。引入消息机制后,同步的事务操作变为基于消息执行的异步操作,避免了分布式事务中的同步阻塞操作的影响,并实现了两个服务的解耦,典型的场景:注册送积分,登陆送优惠券等
  • 最大努力通知是分布式事务中要求最低的一种,适用于一些最终一致性时间敏感度低的业务,允许发起通知方业务处理失败,在接收通知方收到通知后积极进行失败处理,无论发起通知方如何处理结果都不会影响到接收通知方的后续处理,发起通知方需提供查询执行情况接口,用于接收通知方校对结果,典型的应用场景:银行通知,支付结果通知等。
2PC TCC 可靠消息 最大努力通知
一致性 强一直性 最终一致 最终一致 最终一致
吞吐量 低 中 高 高
实现复杂度 容易 难 中 容易

案列Demo源码地址

  • github.com/qinxuewu/bo…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

不容错过的 Babel7 知识

发表于 2019-12-02

对 Babel 的配置项的作用不那么了解,是否会影响日常开发呢?老实说,大多情况下没有特别大的影响(毕竟有搜索引擎)。

不过呢,还是想更进一步了解下,于是最近认真阅读了 Babel 的文档,外加不断编译验证,输出了本篇文章,为了更好的阅读体验,修修改改,最终算是以我个人比较喜欢的方式推进了每个知识点(每一个配置的引入都是有原因的),希望能够帮助你对 Babel 的各种配置有一个更清晰的认识 (已经很懂的小伙伴,无视本文) 。

Babel 是一个 JS 编译器

Babel 是一个工具链,主要用于将 ECMAScript 2015+ 版本的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境中。

我们先看看 Babel 能够做什么:

  • 语法转换
  • 通过 Polyfill 方式在目标环境中添加缺失的特性(@babel/polyfill模块)
  • 源码转换(codemods)

本篇文章的目的是搞明白 Babel 的使用和配置,搞清楚 @babel/runtime,@babel/polyfill,@babel/plugin-transform-runtime 这些作用是什么,插件和预设都是用来干什么的,我们为什么需要配置它们,而不是讲如何进行 AST 转换,如果你对 AST 转换非常感兴趣,欢迎阅读我们的 RN转小程序引擎 Alita 的源码,其中应用了大量的 AST 转换。

更多文章可戳(如Star,谢谢你):https://github.com/YvetteLau/Blog

为了更清晰的了解每一步,首先创建一个新项目,例如 babelTemp(你爱取啥名取啥名),使用 npm init -y 进行初始化,创建 src/index.js,文件内容如下(你也可以随便写点什么):

1
2
3
js复制代码const fn = () => {  
    console.log('a');
};

OK,创建好的项目先放在一边,先了解下理论知识:

核心库 @babel/core

Babel 的核心功能包含在 @babel/core 模块中。看到 core 这个词了吧,意味着核心,没有它,在 babel 的世界里注定寸步难行。不安装 @babel/core,无法使用 babel 进行编译。

CLI命令行工具 @babel/cli

babel 提供的命令行工具,主要是提供 babel 这个命令,适合安装在项目里。

@babel/node 提供了 babel-node 命令,但是 @babel/node 更适合全局安装,不适合安装在项目里。

1
shell复制代码npm install --save-dev @babel/core @babel/cli

现在你就可以在项目中使用 babel 进行编译啦(如果不安装 @babel/core,会报错噢)

将命令配置在 package.json 文件的 scripts 字段中:

1
2
3
4
json复制代码//...  
"scripts": {
    "compiler": "babel src --out-dir lib --watch"
}

使用 npm run compiler 来执行编译,现在我们没有配置任何插件,编译前后的代码是完全一样的。

因为 Babel 虽然开箱即用,但是什么动作也不做,如果想要 Babel 做一些实际的工作,就需要为其添加插件(plugin)。

插件

Babel 构建在插件之上,使用现有的或者自己编写的插件可以组成一个转换通道,Babel 的插件分为两种: 语法插件和转换插件。

语法插件

这些插件只允许 Babel 解析(parse) 特定类型的语法(不是转换),可以在 AST 转换时使用,以支持解析新语法,例如:

1
2
3
4
5
6
js复制代码import * as babel from "@babel/core";  
const code = babel.transformFromAstSync(ast, {
    //支持可选链
    plugins: ["@babel/plugin-proposal-optional-chaining"],
    babelrc: false
}).code;

转换插件

转换插件会启用相应的语法插件(因此不需要同时指定这两种插件),这点很容易理解,如果不启用相应的语法插件,意味着无法解析,连解析都不能解析,又何谈转换呢?

插件的使用

如果插件发布在 npm 上,可以直接填写插件的名称, Babel 会自动检查它是否已经被安装在 node_modules 目录下,在项目目录下新建 .babelrc 文件 (下文会具体介绍配置文件),配置如下:

1
2
3
4
json复制代码//.babelrc  
{
    "plugins": ["@babel/plugin-transform-arrow-functions"]
}

也可以指定插件的相对/绝对路径

1
2
3
json复制代码{  
    "plugins": ["./node_modules/@babel/plugin-transform-arrow-functions"]
}

执行 npm run compiler,可以看到箭头函数已经被编译OK, lib/index.js 内容如下:

1
2
3
js复制代码const fn = function () {  
    console.log('a');
};

现在,我们仅支持转换箭头函数,如果想将其它的新的JS特性转换成低版本,需要使用其它对应的 plugin 。如果我们一个个配置的话,会非常繁琐,因为你可能需要配置几十个插件,这显然非常不便,那么有没有什么办法可以简化这个配置呢?

有!预设!(感谢强大的 Babel)

预设

通过使用或创建一个 preset 即可轻松使用一组插件。

官方 Preset

  • @babel/preset-env
  • @babel/preset-flow
  • @babel/preset-react
  • @babel/preset-typescript

注: 从 Babel v7 开始,所有针对标准提案阶段的功能所编写的预设(stage preset)都已被弃用,官方已经移除了 @babel/preset-stage-x。

@babel/preset-env

@babel/preset-env 主要作用是对我们所使用的并且目标浏览器中缺失的功能进行代码转换和加载 polyfill,在不进行任何配置的情况下,@babel/preset-env 所包含的插件将支持所有最新的JS特性(ES2015,ES2016等,不包含 stage 阶段),将其转换成ES5代码。例如,如果你的代码中使用了可选链(目前,仍在 stage 阶段),那么只配置 @babel/preset-env,转换时会抛出错误,需要另外安装相应的插件。

1
2
3
4
json复制代码//.babelrc  
{
    "presets": ["@babel/preset-env"]
}

需要说明的是,@babel/preset-env 会根据你配置的目标环境,生成插件列表来编译。对于基于浏览器或 Electron 的项目,官方推荐使用 .browserslistrc 文件来指定目标环境。默认情况下,如果你没有在 Babel 配置文件中(如 .babelrc)设置 targets 或 ignoreBrowserslistConfig,@babel/preset-env 会使用 browserslist 配置源。

如果你不是要兼容所有的浏览器和环境,推荐你指定目标环境,这样你的编译代码能够保持最小。

例如,仅包括浏览器市场份额超过0.25%的用户所需的 polyfill 和代码转换(忽略没有安全更新的浏览器,如 IE10 和 BlackBerry):

1
2
3
json复制代码//.browserslistrc  
> 0.25%
not dead

查看 browserslist 的更多配置

例如,你将 .browserslistrc 的内容配置为:

1
json复制代码last 2 Chrome versions

然后再执行 npm run compiler,你会发现箭头函数不会被编译成ES5,因为 chrome 的最新2个版本都能够支持箭头函数。现在,我们将 .browserslistrc 仍然换成之前的配置。

就咱们目前的代码来说,当前的配置似乎已经是OK的了。

我们修改下 src/index.js。

1
2
3
4
5
js复制代码const isHas = [1,2,3].includes(2);  

const p = new Promise((resolve, reject) => {
    resolve(100);
});

编译出来的结果为:

1
2
3
4
5
6
js复制代码"use strict";  

var isHas = [1, 2, 3].includes(2);
var p = new Promise(function (resolve, reject) {
  resolve(100);
});

这个编译出来的代码在低版本浏览器中使用的话,显然是有问题的,因为低版本浏览器中数组实例上没有 includes 方法,也没有 Promise 构造函数。

这是为什么呢?因为语法转换只是将高版本的语法转换成低版本的,但是新的内置函数、实例方法无法转换。这时,就需要使用 polyfill 上场了,顾名思义,polyfill的中文意思是垫片,所谓垫片就是垫平不同浏览器或者不同环境下的差异,让新的内置函数、实例方法等在低版本浏览器中也可以使用。

Polyfill

@babel/polyfill 模块包括 core-js 和一个自定义的 regenerator runtime 模块,可以模拟完整的 ES2015+ 环境(不包含第4阶段前的提议)。

这意味着可以使用诸如 Promise 和 WeakMap 之类的新的内置组件、 Array.from 或 Object.assign 之类的静态方法、Array.prototype.includes 之类的实例方法以及生成器函数(前提是使用了 @babel/plugin-transform-regenerator 插件)。为了添加这些功能,polyfill 将添加到全局范围和类似 String 这样的内置原型中(会对全局环境造成污染,后面我们会介绍不污染全局环境的方法)。

补充说明 (2020/01/07):V7.4.0 版本开始,@babel/polyfill 已经被废弃(前端发展日新月异),需单独安装 core-js 和 regenerator-runtime 模块。

首先,安装 @babel/polyfill 依赖:

1
shell复制代码npm install --save @babel/polyfill

注意:不使用 --save-dev,因为这是一个需要在源码之前运行的垫片。

我们需要将完整的 polyfill 在代码之前加载,修改我们的 src/index.js:

1
2
3
4
5
6
7
js复制代码import '@babel/polyfill';  

const isHas = [1,2,3].includes(2);

const p = new Promise((resolve, reject) => {
    resolve(100);
});

@babel/polyfill 需要在其它代码之前引入,我们也可以在 webpack 中进行配置。

例如:

1
2
3
4
js复制代码entry: [  
    require.resolve('./polyfills'),
    path.resolve('./index')
]

polyfills.js 文件内容如下:

1
2
js复制代码//当然,还可能有一些其它的 polyfill,例如 stage 4之前的一些 polyfill  
import '@babel/polyfill';

现在,我们的代码不管在低版本还是高版本浏览器(或node环境)中都能正常运行了。不过,很多时候,我们未必需要完整的 @babel/polyfill,这会导致我们最终构建出的包的体积增大,@babel/polyfill的包大小为89K (当前 @babel/polyfill 版本为 7.7.0)。

我们更期望的是,如果我使用了某个新特性,再引入对应的 polyfill,避免引入无用的代码。

值得庆幸的是, Babel 已经考虑到了这一点。

@babel/preset-env 提供了一个 useBuiltIns 参数,设置值为 usage 时,就只会包含代码需要的 polyfill 。有一点需要注意:配置此参数的值为 usage ,必须要同时设置 corejs (如果不设置,会给出警告,默认使用的是”corejs”: 2) ,注意: 这里仍然需要安装 @babel/polyfill(当前 @babel/polyfill 版本默认会安装 “corejs”: 2):

首先说一下使用 core-js@3 的原因,core-js@2 分支中已经不会再添加新特性,新特性都会添加到 core-js@3。例如你使用了 Array.prototype.flat(),如果你使用的是 core-js@2,那么其不包含此新特性。为了可以使用更多的新特性,建议大家使用 core-js@3。

安装依赖依赖:

1
shell复制代码npm install --save core-js@3

core-js (点击了解更多) : JavaScript 的模块化标准库,包含 Promise、Symbol、Iterator和许多其他的特性,它可以让你仅加载必需的功能。

现在,修改 Babel 的配置文件如下:

1
2
3
4
5
6
7
8
9
10
js复制代码//.babelrc  
const presets = [
    [
        "@babel/env",
        {   
            "useBuiltIns": "usage",
            "corejs": 3
        }
    ]
]

Babel 会检查所有代码,以便查找在目标环境中缺失的功能,然后仅仅把需要的 polyfill 包含进来。

例如,src/index.js 代码不变:

1
2
3
4
5
js复制代码const isHas = [1,2,3].includes(2);  

const p = new Promise((resolve, reject) => {
    resolve(100);
});

我们看看编译出来的文件(lib/index):

1
2
3
4
5
6
7
8
9
10
11
12
js复制代码"use strict";  

require("core-js/modules/es.array.includes");

require("core-js/modules/es.object.to-string");

require("core-js/modules/es.promise");

var isHas = [1, 2, 3].includes(2);
var p = new Promise(function (resolve, reject) {
    resolve(100);
});

同样的代码,我们用 webpack 构建一下(production 模式),能看到最终的代码大小仅为: 20KB。而如果我们引入整个 @babel/polyfill 的话,构建出的包大小为:89KB

前面曾提到,在 useBuiltIns 参数值为 usage 时,仍然需要安装 @babel/polyfill,虽然我们上面的代码转换中看起来并没有使用到,但是,如果我们源码中使用到了 async/await,那么编译出来的代码需要 require("regenerator-runtime/runtime"),在 @babel/polyfill 的依赖中,当然啦,你也可以只安装 regenerator-runtime/runtime 取代安装 @babel/polyfill。

到了这一步,已经很棒棒了,是不是想跳起来转个圈圈?

下面我要说的内容,也许你已经知道,也许你还不知道,这都不重要,但是此刻起,你要知道了: Babel 会使用很小的辅助函数来实现类似 _createClass 等公共方法。默认情况下,它将被添加(inject)到需要它的每个文件中。

假如,我们的 src/index.js 是这样的:

1
2
3
4
5
6
7
8
9
10
11
js复制代码class Point {  
    constructor(x, y) {
        this.x = x;
        this.y = y;
    };
    getX() {
        return this.x;
    }
}

let cp = new ColorPoint(25, 8);

编译出来的 lib/index.js,如下所示:

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
js复制代码"use strict";  

require("core-js/modules/es.object.define-property");

function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }

function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }

var Point =
    /*#__PURE__*/
    function () {
        function Point(x, y) {
            _classCallCheck(this, Point);

            this.x = x;
            this.y = y;
        }

        _createClass(Point, [{
            key: "getX",
            value: function getX() {
                return this.x;
            }
        }]);

        return Point;
    }();

var cp = new ColorPoint(25, 8);

看起来,似乎并没有什么问题,但是你想一下,如果你有10个文件中都使用了这个 class,是不是意味着 _classCallCheck、_defineProperties、_createClass 这些方法被 inject 了10次。这显然会导致包体积增大,最关键的是,我们并不需要它 inject 多次。

这个时候,就是 @babel/plugin-transform-runtime 插件大显身手的时候了,使用 @babel/plugin-transform-runtime 插件,所有帮助程序都将引用模块 @babel/runtime,这样就可以避免编译后的代码中出现重复的帮助程序,有效减少包体积。

@babel/plugin-transform-runtime

@babel/plugin-transform-runtime 是一个可以重复使用 Babel 注入的帮助程序,以节省代码大小的插件。

注意:诸如 Array.prototype.flat() 等实例方法将不起作用,因为这需要修改现有的内置函数(可以使用 @babel/polyfill 来解决这个问题) ——> 对此需要说明的是如果你配置的是corejs3, core-js@3 现在已经支持原型方法,同时不污染原型。

另外,@babel/plugin-transform-runtime 需要和 @babel/runtime 配合使用。

首先安装依赖,@babel/plugin-transform-runtime 通常仅在开发时使用,但是运行时最终代码需要依赖 @babel/runtime,所以 @babel/runtime 必须要作为生产依赖被安装,如下 :

1
2
shell复制代码npm install --save-dev @babel/plugin-transform-runtime  
npm install --save @babel/runtime

除了前文所说的,@babel/plugin-transform-runtime 可以减少编译后代码的体积外,我们使用它还有一个好处,它可以为代码创建一个沙盒环境,如果使用 @babel/polyfill 及其提供的内置程序(例如 Promise ,Set 和 Map ),则它们将污染全局范围。虽然这对于应用程序或命令行工具可能是可以的,但是如果你的代码是要发布供他人使用的库,或者无法完全控制代码运行的环境,则将成为一个问题。

@babel/plugin-transform-runtime 会将这些内置别名作为 core-js 的别名,因此您可以无缝使用它们,而无需 polyfill。

修改 .babelrc 的配置,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
json复制代码//.babelrc  
{
    "presets": [
        [
            "@babel/preset-env",
            {
                "useBuiltIns": "usage",
                "corejs": 3
            }
        ]
    ],
    "plugins": [
        [
            "@babel/plugin-transform-runtime"
        ]
    ]
}

重新编译 npm run compiler , 现在,编译出来的内容为(lib/index.js):

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
js复制代码"use strict";  

var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");

var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));

var _createClass2 = _interopRequireDefault(require("@babel/runtime/helpers/createClass"));

var Point =
    /*#__PURE__*/
    function () {
        function Point(x, y) {
            (0, _classCallCheck2.default)(this, Point);
            this.x = x;
            this.y = y;
        }

        (0, _createClass2.default)(Point, [{
            key: "getX",
            value: function getX() {
                return this.x;
            }
        }]);
        return Point;
    }();

var cp = new ColorPoint(25, 8);

可以看出,帮助函数现在不是直接被 inject 到代码中,而是从 @babel/runtime 中引入。前文说了使用 @babel/plugin-transform-runtime 可以避免全局污染,我们来看看是如何避免污染的。

修改 src/index.js 如下:

1
2
3
4
5
js复制代码let isHas = [1,2,3].includes(2);  

new Promise((resolve, reject) => {
    resolve(100);
});

编译出来的代码如下(lib/index.js):

1
2
3
4
5
6
7
8
9
10
11
12
js复制代码"use strict";  

require("core-js/modules/es.array.includes");

require("core-js/modules/es.object.to-string");

require("core-js/modules/es.promise");

var isHas = [1, 2, 3].includes(2);
new Promise(function (resolve, reject) {
    resolve(100);
});

Array.prototype 上新增了 includes 方法,并且新增了全局的 Promise 方法,污染了全局环境,这跟不使用 @babel/plugin-transform-runtime 没有区别嘛。

如果我们希望 @babel/plugin-transform-runtime 不仅仅处理帮助函数,同时也能加载 polyfill 的话,我们需要给 @babel/plugin-transform-runtime 增加配置信息。

首先新增依赖 @babel/runtime-corejs3:

1
shell复制代码npm install @babel/runtime-corejs3 --save

修改配置文件如下(移除了 @babel/preset-env 的 useBuiltIns 的配置,不然不就重复了嘛嘛嘛,不信的话,你用 async/await 编译下试试咯):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
js复制代码{  
    "presets": [
        [
            "@babel/preset-env"
        ]
    ],
    "plugins": [
        [
            "@babel/plugin-transform-runtime",{
                "corejs": 3
            }
        ]
    ]
}

然后重新编译,看一下,编译出来的结果(lib/index.js):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
js复制代码"use strict";  

var _interopRequireDefault = require("@babel/runtime-corejs3/helpers/interopRequireDefault");

var _promise = _interopRequireDefault(require("@babel/runtime-corejs3/core-js-stable/promise"));

var _includes = _interopRequireDefault(require("@babel/runtime-corejs3/core-js-stable/instance/includes"));

var _context;

var isHas = (0, _includes.default)(_context = [1, 2, 3]).call(_context, 2);
new _promise.default(function (resolve, reject) {
  resolve(100);
});

可以看出,没有直接去修改 Array.prototype,或者是新增 Promise 方法,避免了全局污染。如果上面 @babel/plugin-transform-runtime 配置的 core-js 是 “2”,其中不包含实例的 polyfill 需要单独引入。

划重点:如果我们配置的 corejs 是 3 版本,那么不管是实例方法还是全局方法,都不会再污染全局环境。

看到这里,不知道大家有没有这样一个疑问?给 @babel/plugin-transform-runtime 配置 corejs 是如此的完美,既可以将帮助函数变成引用的形式,又可以动态引入 polyfill,并且不会污染全局环境。何必要给 @babel/preset-env 提供 useBuiltIns 功能呢,看起来似乎不需要呀。

带着这样的疑问,我新建了几个文件(内容简单且基本一致,使用了些新特性),然后使用 webpack 构建,以下是我对比的数据:

序号 .babelrc 配置 webpack mode production
0 不使用 @babel/plugin-transform-runtime 36KB
1 使用@babel/plugin-transform-runtime,并配置参数 corejs: 3。不会污染全局环境 37KB
2 使用@babel/plugin-transform-runtime,不配置 corejs 22KB

我猜测是 @babel/runtime-corejs3/XXX 的包本身比 core-js/modules/XXX 要大一些~

插件/预设补充知识

插件的排列顺序很重要!!!

如果两个转换插件都将处理“程序(Program)”的某个代码片段,则将根据转换插件或 preset 的排列顺序依次执行。

  • 插件在 Presets 前运行。
  • 插件顺序从前往后排列。
  • Preset 顺序是颠倒的(从后往前)。

例如:

1
2
3
json复制代码{  
    "plugins": ["@babel/plugin-proposal-class-properties", "@babel/plugin-syntax-dynamic-import"]
}

先执行 @babel/plugin-proposal-class-properties,后执行 @babel/plugin-syntax-dynamic-import

1
2
3
json复制代码{  
  "presets": ["@babel/preset-env", "@babel/preset-react"]
}

preset 的执行顺序是颠倒的,先执行 @babel/preset-react, 后执行 @babel/preset-env。

插件参数

插件和 preset 都可以接受参数,参数由插件名和参数对象组成一个数组。preset 设置参数也是这种格式。

如:

1
2
3
4
5
6
7
8
json复制代码{  
    "plugins": [
        [
            "@babel/plugin-proposal-class-properties", 
            { "loose": true }
        ]
    ]
}
插件的短名称

如果插件名称为 @babel/plugin-XXX,可以使用短名称@babel/XXX :

1
2
3
4
5
js复制代码{  
    "plugins": [
        "@babel/transform-arrow-functions" //同 "@babel/plugin-transform-arrow-functions"
    ]
}

如果插件名称为 babel-plugin-XXX,可以使用短名称 XXX,该规则同样适用于带有 scope 的插件:

1
2
3
4
5
6
js复制代码{  
    "plugins": [
        "newPlugin", //同 "babel-plugin-newPlugin"
        "@scp/myPlugin" //同 "@scp/babel-plugin-myPlugin"
    ]
}

创建 Preset

可以简单的返回一个插件数组

1
2
3
4
5
6
7
8
9
js复制代码module.exports = function() {  
    return {
        plugins: [
            "A",
            "B",
            "C"
        ]
    }
}

preset 中也可以包含其他的 preset,以及带有参数的插件。

1
2
3
4
5
6
7
8
9
10
11
js复制代码module.exports = function() {  
    return {
        presets: [
            require("@babel/preset-env")
        ],
        plugins: [
            [require("@babel/plugin-proposal-class-properties"), { loose: true }],
            require("@babel/plugin-proposal-object-rest-spread")
        ]
    }
}

配置文件

Babel 支持多种格式的配置文件。这部分内容补充了解下即可,谁管你用哪种配置文件,只要你的配置是OK的就可以了(敷衍)~

所有的 Babel API 参数都可以被配置,但是如果该参数需要使用的 JS 代码,那么可能需要使用 JS 代码版的配置文件。

根据使用场景可以选择不同的配置文件:

如果希望以编程的方式创建配置文件或者希望编译 node_modules 目录下的模块:那么 babel.config.js 可以满足你的需求。

如果只是需要一个简单的并且中用于单个软件包的配置:那么 .babelrc 即可满足你的需求。

babel.config.js

在项目根目录下创建一个名为 babel.config.js 的文件。

1
2
3
4
5
6
7
8
9
10
11
js复制代码module.exports = function(api) {  
    api.cache(true);

    const presets = [...];
    const plugins = [...];

    return {
        presets,
        plugins
    };
}

具体的配置可以查看:babel.config.js 文档

.babelrc

在项目根目录下创建一个名为 .babelrc 的文件:

1
2
3
4
复制代码{  
    "presets": [],
    "plugins": []
}

具体的配置可以参考 .babelrc 文档

package.json

可以将 .babelrc 中的配置信息作为 babel 键(key) 添加到 package.json 文件中:

1
2
3
4
5
6
7
json复制代码{  
    "name": "my-package",
    "babel": {
        "presets": [],
        "plugins": []
    }
}

.babelrc.js

与 .babelrc 配置相同,但是可以使用JS编写。

1
2
3
4
5
js复制代码//可以在其中调用 Node.js 的API  
const presets = [];
const plugins = [];

module.exports = { presets, plugins };

不知道是否全面,不过真的写不动了(如有不全,后续再补充)就酱如果有错误,欢迎指正。

参考链接

  1. babel文档
  2. babel 7 的使用的个人理解
  3. 一口(很长的)气了解 babel
  4. core-js@3带来的惊喜

关注“小姐姐”的公众号

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…844845846…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%