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

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


  • 首页

  • 归档

  • 搜索

你懂HashCode?你不懂!

发表于 2020-11-11

在讨论hashCode之前,我先提出几个问题,后面我会一一解答

  1. 什么是hashCode?
  2. hashCode有什么用?
  3. 为什么会发生哈希冲突,可以避免吗?
  4. 为什么重写equals()方法一定要重写hashCode()方法?
  5. hashCode在Java中是如何生成的?

在讨论上述问题之前,我们先看看java里面是如何对hashCode定义的,下面我摘抄一段java8 Object对hashCode()的一段注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码    /**
* Returns a hash code value for the object. This method is
* supported for the benefit of hash tables such as those provided by
* {@link java.util.HashMap}.
* <p>
* The general contract of {@code hashCode} is:
* <ul>
* <li>Whenever it is invoked on the same object more than once during
* an execution of a Java application, the {@code hashCode} method
* must consistently return the same integer, provided no information
* used in {@code equals} comparisons on the object is modified.
* This integer need not remain consistent from one execution of an
* application to another execution of the same application.
* <li>If two objects are equal according to the {@code equals(Object)}
* method, then calling the {@code hashCode} method on each of
* the two objects must produce the same integer result.
* <li>It is <em>not</em> required that if two objects are unequal
* according to the {@link java.lang.Object#equals(java.lang.Object)}
* method, then calling the {@code hashCode} method on each of the
* two objects must produce distinct integer results. However, the
* programmer should be aware that producing distinct integer results
* for unequal objects may improve the performance of hash tables.
* </ul>
*/

我把他的意思大致归纳于以下几点

  1. 每个对象都能返回一个hashCode,hashCode是用来加速哈希表的
  2. 在同一java应用程序执行期间,对同一个对象调用超过一次时,如果equals()比较的内容没有发生变化,hashCode()的返回应该也不应该发生变化。(特别需要注意的是同一java程序,不同java程序可以允许hashCode发生变化)
  3. 根据equals()方法,两个对象相等,那么hashCode也必须要相等
  4. 根据equals()方法,两个对象不相等,那么hashCode可能相等,但是要尽可能的让他们不相等,这样可以提升哈希表的性能

什么是hashCode?

hashCode是用来加速哈希表的,在同一个java应用程序中,hashCode是对java对象的一个签名,相等的对象hashCode一定相等。

hashCode有什么用?

上面也已经提到了,是用来加速哈希表的,采取了空间换时间的方式。

这一篇博客不是说哈希表的,因此这里不做展开

大致思路是先通过hashcode去哈希表里面找到对应的哈希桶,然后调用equals()方法去判断哈希桶里面挂载的元素有没有相等的。

为什么相等的元素会出现在同一个哈希桶里面?

前文已经说过了,相等的对象hashcode一定相等,hashcode相等的,对象却不一定相等,因此需要再调用equals()方法做判断

为什么会发生哈希冲突,可以避免吗?

先来看一下java里面hashCode的方法签名

1
java复制代码public native int hashCode();

可以看到hashCode本质上面来说是一个int,int的表示的数据范围是有限的,而对象是无限的,把一个无限的集合压缩进一个有限的集合中,发生哈希冲突就成了一个必然事件,哈希冲突是无法避免的。

当然,在我们的应用中,对象不可能是无穷无尽的,因此选择好的哈希算法是可以降低哈希冲突的概率的

为什么重写equals()方法一定要重写hashCode()方法?

这个问题实际上前面已经多次提到了,根据java规范,equals()相等的对象,hashCode()必须相等

重写equals()必然导致对象是否相等的结果发生变化,因此也就需要修改hashCode()方法

如果只修改equals()不修改hashCode()方法,就会导致使用哈希表的时候不能准确定位到哈希桶,导致哈希表工作异常

hashCode在Java中是如何生成的?

这个问题其实很复杂,我再摘抄一段java8 Object里面的注释

1
2
3
4
5
6
7
8
java复制代码    /**
* As much as is reasonably practical, the hashCode method defined by
* class {@code Object} does return distinct integers for distinct
* objects. (This is typically implemented by converting the internal
* address of the object into an integer, but this implementation
* technique is not required by the
* Java&trade; programming language.)
*/

这句话的意思是说,很多时候hashcode是对象内存地址的一个映射,但是java里面不是的。

那么java里面的hashCode是如何生成的呢?

实际上java默认的hashcode是由一套随机算法生成的,只与对象生成的顺序和线程有关。

下面我用代码证明下

1
2
3
4
5
6
7
java复制代码    public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
Object o = new Object();
System.out.println("hashCode:" + o.hashCode());
System.out.println("address:" + VM.current().addressOf(o));
}
}

可以直接用Unsafe类拿到内存地址,不过操作比较繁琐,因此我用了别人封装的工具包

1
2
3
4
5
xml复制代码        <dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>

反复执行上面代码,结果如下

第一次 第二次 第三次
hashCode:531885035 address:31856529176 hashCode:705265961 address:31874395832 hashCode:428746855 address:31874396520 hashCode:317983781 address:31874397208 hashCode:987405879 address:31874397896 hashCode:531885035 address:31856529344 hashCode:705265961 address:31874395856 hashCode:428746855 address:31874396544 hashCode:317983781 address:31874397232 hashCode:987405879 address:31874397920 hashCode:531885035 address:31856529920 hashCode:705265961 address:31874407200 hashCode:428746855 address:31874407888 hashCode:317983781 address:31874408576 hashCode:987405879 address:31874409264

我们可以清楚的看到,相同的顺序hashCode是一样的,内存地址是不一样的,因此可以肯定java的hashCode和内存是无关的,看起来与创建顺序有关。

接下来我们用代码去查看发生哈希冲突时的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码    public static void main(String[] args) {
HashSet<Integer> set = new HashSet<Integer>();
int count = 0, num = 0;
while (true) {
count++;
Object o = new Object();
if (set.contains(o.hashCode())) {
num++;
System.out.println("count:" + count);
System.out.println("hashCode:" + o.hashCode());
if (num == 5) break;
}
set.add(o.hashCode());
}
}

重复运行上面代码

第一次 第二次 第三次
count:105730 hashCode:2134400190 count:111177 hashCode:651156501 count:121838 hashCode:1867750575 count:145361 hashCode:2038112324 count:146294 hashCode:1164664992 count:105730 hashCode:2134400190 count:111177 hashCode:651156501 count:121838 hashCode:1867750575 count:145361 hashCode:2038112324 count:146294 hashCode:1164664992 count:105730 hashCode:2134400190 count:111177 hashCode:651156501 count:121838 hashCode:1867750575 count:145361 hashCode:2038112324 count:146294 hashCode:1164664992

我们可以看到,发生哈希冲突的位置是一样的,发生哈希冲突时的hashCode也是一样的,因此我们可以断定hashcode的生成与对象生成的顺序有关。

接下来我们用不同的线程去重复上述操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码    public static void main(String[] args) throws InterruptedException {
new Thread(() ->{
HashSet<Integer> set = new HashSet<Integer>();
int count = 0, num = 0;
while (true) {
count++;
Object o = new Object();
if (set.contains(o.hashCode())) {
num++;
System.out.println("count:" + count);
System.out.println("hashCode:" + o.hashCode());
if (num == 5) break;
}
set.add(o.hashCode());
}
}).start();
Thread.sleep(1000L);
}

结果如下

第一次 第二次 第三次
count:53869 hashCode:820543451 count:87947 hashCode:951498229 count:99859 hashCode:175788428 count:100940 hashCode:1979813773 count:123438 hashCode:916565907 count:51579 hashCode:1573800482 count:124111 hashCode:1834341042 count:139761 hashCode:2021276643 count:142276 hashCode:1807321828 count:143515 hashCode:1522017140 count:69186 hashCode:742597489 count:102967 hashCode:2084692455 count:144290 hashCode:424089733 count:176112 hashCode:53258565 count:178639 hashCode:1046986547

看起来没有任何规律了,这就需要去扒java的源码才能解答这个现象了

Object源码地址

在Object中我们可以看到hashCode实际上是调用了IHashCode

1
2
3
4
5
6
7
c复制代码static JNINativeMethod methods[] = {
{"hashCode", "()I", (void *)&JVM_IHashCode},
{"wait", "(J)V", (void *)&JVM_MonitorWait},
{"notify", "()V", (void *)&JVM_MonitorNotify},
{"notifyAll", "()V", (void *)&JVM_MonitorNotifyAll},
{"clone", "()Ljava/lang/Object;", (void *)&JVM_Clone},
};

然后在jvm.cpp找到了IHashCode实际调用的是FastHashCode

1
2
3
4
5
c复制代码JVM_ENTRY(jint, JVM_IHashCode(JNIEnv* env, jobject handle))
JVMWrapper("JVM_IHashCode");
// as implemented in the classic virtual machine; return 0 if object is NULL
return handle == NULL ? 0 : ObjectSynchronizer::FastHashCode (THREAD, JNIHandles::resolve_non_null(handle)) ;
JVM_END

接着扒FastHashCode,FastHashCode在synchronizer.cpp中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
c复制代码intptr_t ObjectSynchronizer::FastHashCode (Thread * Self, oop obj) {
if (UseBiasedLocking) {
// NOTE: many places throughout the JVM do not expect a safepoint
// to be taken here, in particular most operations on perm gen
// objects. However, we only ever bias Java instances and all of
// the call sites of identity_hash that might revoke biases have
// been checked to make sure they can handle a safepoint. The
// added check of the bias pattern is to avoid useless calls to
// thread-local storage.
if (obj->mark()->has_bias_pattern()) {
// Box and unbox the raw reference just in case we cause a STW safepoint.
Handle hobj (Self, obj) ;
// Relaxing assertion for bug 6320749.
assert (Universe::verify_in_progress() ||
!SafepointSynchronize::is_at_safepoint(),
"biases should not be seen by VM thread here");
BiasedLocking::revoke_and_rebias(hobj, false, JavaThread::current());
obj = hobj() ;
assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now");
}
}

// hashCode() is a heap mutator ...
// Relaxing assertion for bug 6320749.
assert (Universe::verify_in_progress() ||
!SafepointSynchronize::is_at_safepoint(), "invariant") ;
assert (Universe::verify_in_progress() ||
Self->is_Java_thread() , "invariant") ;
assert (Universe::verify_in_progress() ||
((JavaThread *)Self)->thread_state() != _thread_blocked, "invariant") ;

ObjectMonitor* monitor = NULL;
markOop temp, test;
intptr_t hash;
markOop mark = ReadStableMark (obj);

// object should remain ineligible for biased locking
assert (!mark->has_bias_pattern(), "invariant") ;
//mark是对象头
if (mark->is_neutral()) {
hash = mark->hash(); // 取出hash值(实际上可以理解为缓存,有就直接返回,没有就生成一个新的)
if (hash) { // if it has hash, just return it
return hash;
}
hash = get_next_hash(Self, obj); // 这是生成hashcode的核心方法
temp = mark->copy_set_hash(hash); // merge the hash code into header
// use (machine word version) atomic operation to install the hash
test = (markOop) Atomic::cmpxchg_ptr(temp, obj->mark_addr(), mark);
if (test == mark) {
return hash;
}
// If atomic operation failed, we must inflate the header
// into heavy weight monitor. We could add more code here
// for fast path, but it does not worth the complexity.
} else if (mark->has_monitor()) {
monitor = mark->monitor();
temp = monitor->header();
assert (temp->is_neutral(), "invariant") ;
hash = temp->hash();
if (hash) {
return hash;
}
// Skip to the following code to reduce code size
} else if (Self->is_lock_owned((address)mark->locker())) {
temp = mark->displaced_mark_helper(); // this is a lightweight monitor owned
assert (temp->is_neutral(), "invariant") ;
hash = temp->hash(); // by current thread, check if the displaced
if (hash) { // header contains hash code
return hash;
}
// WARNING:
// The displaced header is strictly immutable.
// It can NOT be changed in ANY cases. So we have
// to inflate the header into heavyweight monitor
// even the current thread owns the lock. The reason
// is the BasicLock (stack slot) will be asynchronously
// read by other threads during the inflate() function.
// Any change to stack may not propagate to other threads
// correctly.
}

// Inflate the monitor to set hash code
monitor = ObjectSynchronizer::inflate(Self, obj);
// Load displaced header and check it has hash code
mark = monitor->header();
assert (mark->is_neutral(), "invariant") ;
hash = mark->hash();
if (hash == 0) {
hash = get_next_hash(Self, obj);
temp = mark->copy_set_hash(hash); // merge hash code into header
assert (temp->is_neutral(), "invariant") ;
test = (markOop) Atomic::cmpxchg_ptr(temp, monitor, mark);
if (test != mark) {
// The only update to the header in the monitor (outside GC)
// is install the hash code. If someone add new usage of
// displaced header, please update this code
hash = test->hash();
assert (test->is_neutral(), "invariant") ;
assert (hash != 0, "Trivial unexpected object/monitor header usage.");
}
}
// We finally get the hash
return hash;
}

get_next_hash

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
c复制代码static inline intptr_t get_next_hash(Thread * Self, oop obj) {
intptr_t value = 0 ;
//随机获取一个
if (hashCode == 0) {
// This form uses an unguarded global Park-Miller RNG,
// so it's possible for two threads to race and generate the same RNG.
// On MP system we'll have lots of RW access to a global, so the
// mechanism induces lots of coherency traffic.
value = os::random() ;
} else
//根据内存地址计算一个
if (hashCode == 1) {
// This variation has the property of being stable (idempotent)
// between STW operations. This can be useful in some of the 1-0
// synchronization schemes.
intptr_t addrBits = cast_from_oop<intptr_t>(obj) >> 3 ;
value = addrBits ^ (addrBits >> 5) ^ GVars.stwRandom ;
} else
//返回1
if (hashCode == 2) {
value = 1 ; // for sensitivity testing
} else
//不太清楚什么意思 求大佬解释
if (hashCode == 3) {
value = ++GVars.hcSequence ;
} else
//直接返回内存地址
if (hashCode == 4) {
value = cast_from_oop<intptr_t>(obj) ;
}
//这是一种生成随机数的一种算法
else {
// Marsaglia's xor-shift scheme with thread-specific state
// This is probably the best overall implementation -- we'll
// likely make this the default in future releases.
unsigned t = Self->_hashStateX ;
t ^= (t << 11) ;
Self->_hashStateX = Self->_hashStateY ;
Self->_hashStateY = Self->_hashStateZ ;
Self->_hashStateZ = Self->_hashStateW ;
unsigned v = Self->_hashStateW ;
v = (v ^ (v >> 19)) ^ (t ^ (t >> 8)) ;
Self->_hashStateW = v ;
value = v ;
}

value &= markOopDesc::hash_mask;
if (value == 0) value = 0xBAD ;
assert (value != markOopDesc::no_hash, "invariant") ;
TEVENT (hashCode: GENERATE) ;
return value;
}

到了这里我们基本上就知道了hashcode是怎么生成的了,实际上在jdk1.8在是用的第五种生成方式,我们可以在Linux系统下输入:java -XX:+PrintFlagsFinal -version|grep hashCode命令查看

ok,接下来我们来分析一下第5种方式,看到这个代码我的第一反应是懵逼的,那个什么_hashStateX是个什么鬼?
我们来看一下thead.cpp里面是怎样定义的:

1
2
3
4
5
c复制代码// thread-specific hashCode stream generator state - Marsaglia shift-xor form
_hashStateX = os::random() ;
_hashStateY = 842502087 ;
_hashStateZ = 0x8767 ; // (int)(3579807591LL & 0xffff) ;
_hashStateW = 273326509 ;

在thread里面定义了一个随机数,三个常数,通过这四个数根据上述的算法来生成hashcode。

具体原理请参考论文:Xorshift RNGs

因为在上述算法在,需要得到线程里面的一个随机数作为一个初始值,上述算法在前后具有因果关系,后面的结果是根据前面的结果推算而来的,因此对于相同的线程来说,在某种意义上,对象的hashcode的生成是和顺序有关的。

为什么主函数里面的hashcode的生成一直是有序的呢?因为主线程里面的是固定值。

可见hashCode在1.8中和内存地址是无关的,与所在线程(或者说生成的一个随机数)以及生成顺序有关

本文转载自: 掘金

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

一文读懂Java内存模型(JMM)及volatile关键字

发表于 2020-11-10

点赞再看,养成习惯,公众号搜一搜【一角钱技术】关注更多原创技术文章。本文 GitHub org_hejianhui/JavaStudy 已收录,有我的系列文章。

前言

  • 并发编程从操作系统底层工作的整体认识开始

上一篇我们从操作系统底层工作的整体了解了并发编程在硬件以及操作系统层面的一些知识,本篇我们继续来学习JMM模型以及Volatile关键字的那些面试必问的一些知识点。

什么是JMM模型?

Java 内存模型(Java Memory Model 简称JMM)是一种抽象的概念,并不真实存在,它描述的一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式。JVM运行程序的实体是线程,而每个线程创建时 JVM 都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java 内存模型中规定所有变量都存储在主内存,其主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存考吧到增加的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储这主内存中的变量副本拷贝,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

JMM 不同于 JVM 内存区域模式

JMM 与 JVM 内存区域的划分是不同的概念层次,更恰当说 JMM 描述的是一组规则,通过这组规则控制各个变量在共享数据区域内和私有数据区域的访问方式,JMM是围绕原子性、有序性、可见性展开。JMM 与 Java 内存区域唯一相似点,都存在共享数据区域和私有数据区域,在 JMM 中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。

线程、工作内存、主内存工作交互图(基于JMM规范),如下:

主内存

主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多个线程同一个变量进行访问可能会发送线程安全问题。

工作内存

主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其他线程是不可见的,就算是两个线程执行的是同一段代码,它们也会在各自的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

根据 JVM 虚拟机规范主内存与工作内存的数据存储类型以及操作方式,对于一个实例对象中的成员方法而言,如果方法中包括本地变量是基本数据类型(boolean、type、short、char、int、long、float、double),将直接存储在工作内存的帧栈中,而对象实例将存储在主内存(共享数据区域,堆)中。但对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区。至于 static 变量以及类本身相关信息将会存储在主内存中。

需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用类同一个对象的同一个方法,那么两个线程会将要操作的数据拷贝一份到直接的工作内存中,执行晚操作后才刷新到主内存。模型如下图所示:

Java 内存模型与硬件内存架构的关系

通过对前面的硬件内存架构、Java内存模型以及Java多线程的实现原理的了解,我们应该已经意识到,多线程的执行最终都会映射到硬件处理器上进行执行,但Java内存模型和硬件内存架构并不完全一致。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说 Java 内存模型对内存的划分对硬件内存并没有任何影响,因为 JMM 只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到 CPU 缓存或者寄存器中,因此总体上来说,Java 内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。(注意对于Java内存区域划分也是同样的道理)

JMM 存在的必要性

在明白了 Java 内存区域划分、硬件内存架构、Java多线程的实现原理与Java内存模型的具体关系后,接着来谈谈Java内存模型存在的必要性。

由于JVM运行程序的实体是线程,而每个线程创建时 JVM 都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,线程与主内存中的变量操作必须通过工作内存间接完成,主要过程是将变量从主内存拷贝的每个线程各自的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,如果存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程安全问题。

假设主内存中存在一个共享变量 x ,现在有 A 和 B 两个线程分别对该变量 x=1 进行操作, A/B线程各自的工作内存中存在共享变量副本 x 。假设现在 A 线程想要修改 x 的值为 2,而 B 线程却想要读取 x 的值,那么 B 线程读取到的值是 A 线程更新后的值 2 还是更新钱的值 1 呢?

答案是:不确定。即 B 线程有可能读取到 A 线程更新钱的值 1,也有可能读取到 A 线程更新后的值 2,这是因为工作内存是每个线程私有的数据区域,而线程 A 操作变量 x 时,首先是将变量从主内存拷贝到 A 线程的工作内存中,然后对变量进行操作,操作完成后再将变量 x 写回主内存。而对于 B 线程的也是类似的,这样就有可能造成主内存与工作内存间数据存在一致性问题,假设直接的工作内存中,这样 B 线程读取到的值就是 x=1 ,但是如果 A 线程已将 x=2 写回主内存后,B线程才开始读取的话,那么此时 B 线程读取到的就是 x=2 ,但到达是那种情况先发送呢?

如下图所示案例:

以上关于主内存与工作内存直接的具体交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节,Java内存模型定义来以下八种操作来完成。

数据同步八大原子操作

  1. lock(锁定):作用于主内存的变量,把一个变量标记为一个线程独占状态;
  2. unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
  3. read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以后随后的load工作使用;
  4. load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量;
  5. use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎;
  6. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量;
  7. store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作;
  8. wirte(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量值传送到主内存的变量中。
  • 如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行 read 和 load 操作;
  • 如果把变量从工作内存中同步到主内存中,就需要按顺序地执行 store 和 write 操作。

但Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。

同步规则分析

  1. 不允许一个线程无原因地(没有发生任何 assign 操作)把数据从工作内存同步回主内存中;
  2. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或者 assign)的变量。即就是对一个变量实施 use 和 store 操作之前,必须先自行 assign 和 load 操作;
  3. 一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可不被同一线程重复执行多次,多次执行 lock 后,只有执行相同次数 unlock 操作,变量才会被解锁。lock 和 unlock 必须成对出现;
  4. 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用变量之前需要重新执行 load 或 assign 操作初始化变量的值;
  5. 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量;
  6. 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行store 和 write 操作)。

并发编程的可见性、原子性与有序性问题

原子性

原子性指的是一个操作不可中断,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。

在Java中,对于基本数据类型的变量的读取和赋值操作是原子性操作需要注意的是:对于32位系统来说,long 类型数据和 double 类型数据(对于基本类型数据:byte、short、int、float、boolean、char 读写是原子操作),它们的读写并非原子性的,也就是说如果存在两条线程同时对 long 类型或者 double 类型的数据进行读写是存在相互干扰的,因为对于32位虚拟机来说,每次原子读写是32位,而 long 和 double 则是64位的存储单元,这样回导致一个线程在写时,操作完成前32位的原子操作后,轮到B线程读取时,恰好只读取来后32位的数据,这样可能回读取到一个即非原值又不是线程修改值的变量,它可能是“半个变量”的数值,即64位数据被两个线程分成了两次读取。但也不必太担心,因为读取到“半个变量”的情况比较少,至少在目前的商用虚拟机中,几乎都把64位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意,知道怎么回事即可。

1
2
3
4
java复制代码X=10; //原子性(简单的读取、将数字赋值给变量) 
Y = x; //变量之间的相互赋值,不是原子操作
X++; //对变量进行计算操作
X=x+1;

可见性

理解了指令重排现象后,可见性容易理解了。可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取到这个变量,并且是修改过的新值。

但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量 x 的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量 x 进行操作,但此时A线程工作内存中共享变量 x 对线程B来说并不可见,这种工作内存与主内存同步延迟现象就会造成可见性问题,另外指令重排以及编译器优化也可能回导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实回导致程序乱序执行的问题,从而也就导致可见性问题。

有序性

有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的,这样的理解并没有毛病,比较对于单线程而言确实如此,但对于多线程环境,则可能出现乱序现象,因为程序编译称机器码指令后可能回出现指令重排现象,重排后的指令与原指令的顺序未必一致,要明白的是,在Java程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的,前半句指的是单线程内保证串行语义执行的一致性,后半句则指令重排现象和工作内存与主内存同步延迟现象。

JMM如何解决原子性、可见性和有序性问题

原子性问题

除了 JVM 自身提供的对基本数据类型读写操作的原子性外,可以通过 synchronized 和 Lock 实现原子性。因为 synchronized 和 Lock 能够保证任一时刻只有一个线程访问该代码块。

可见性问题

volatile 关键字可以保证可见性。当一个共享变量被 volatile 关键字修饰时,它会保证修改的值立即被其他的线程看到,即修改的值立即更新到主存中,当其他线程需要读取时,它会去内存中读取新值。synchronized 和 Lock 也可以保证可见性,因为它们可以保证任一时刻只有一个线程能访问共享资源,并在其释放锁之前将修改的变量刷新到内存中。

有序性问题

在Java里面,可以通过 volatile 关键字来保证一定的“有序性”。另外可以通过 synchronized 和 Lock 来保证有序性,很显然,synchronized 和 Lock 保证每个时刻是只有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证来有序性。

Java内存模型

每个线程都有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接对主内存进行操作。并且每个线程不能访问其他线程的工作内存。Java 内存模型具有一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从 happens-before 原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

指令重排序

Java语言规范规定 JVM 线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫做指令的重排序。

指令重排序的意义是什么?JVM能根据处理特性(CPU多级缓存、多核处理器等)适当的对机器指令进行重排序,使机器指令更更符合CPU的执行特性,最大限度的发挥机器性能。

下图为从源码到最终执行的指令序列示意图:

as-if-serial 语义

as-if-serial 语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守 as-if-serial 语义。

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

happens-before 原则

只靠 synchronized 和 volatile 关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,幸运的是,从JDK 5 开始,Java 使用新的 JSR-133 内存模型,提供了 happens-before 原则 来辅助保证程序执行的原子性、可见性和有序性的问题,它是判断数据十分存在竞争、线程十分安全的一句。happens-before 原则内容如下:

  1. 程序顺序原则,即在一个线程内必须保证语义串行,也就是说按照代码顺序执行。
  2. 锁规则,解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
  3. volatile规则, volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
  4. 线程启动规则,线程的 start() 方法先于它的每一个动作,即如果线程A在执行线程B的 start 方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见。
  5. 传递性,A先于B,B先于C,那么A必然先于C。
  6. 线程终止原则,线程的所有操作先于线程的终结,Thread.join() 方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回,线程B对共享变量的修改将对线程A可见。
  7. 线程中断规则,对线程 interrupt() 方法的调用先行发生于被中断线程的代码检查到中断事件的发生,可以通过 Thread.interrupted() 方法检测线程十分中断。
  8. 对象终结规则,对象的构造函数执行,结束先于 finalize() 方法。

finalize()是Object中的方法,当垃圾回收器将要回收对象所占内存之前被调用,即当一个对象被虚拟机宣告死亡时会先调用它finalize()方法,让此对象处理它生前的最后事情(这个对象可以趁这个时机挣脱死亡的命运)。

volatile 内存语义

volatile 是Java虚拟机提供的轻量级的同步机制。volatile 关键字有如下两个作用:

  1. 保证被 volatile 修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了被 volatile 修饰共享变量的值,新值总是可以被其他线程立即得知。
  2. 紧张指令重排序优化。

volatile 的可见性

关于 volatile 的可见性作用,我们必须意思到被 volatile 修饰的变量对所有线程总是立即可见的,对于 volatile 变量的所有写操作总是能立刻反应到其他线程中。

案例:线程A改变 initFlag 属性之后,线程B马上感知到

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
java复制代码package com.niuh.jmm;

import lombok.extern.slf4j.Slf4j;

/**
* @description: -server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*Jmm03_CodeVisibility.refresh
* -Djava.compiler=NONE
**/
@Slf4j
public class Jmm03_CodeVisibility {

private static boolean initFlag = false;

private volatile static int counter = 0;

public static void refresh() {
log.info("refresh data.......");
initFlag = true;
log.info("refresh data success.......");
}

public static void main(String[] args) {
// 线程A
Thread threadA = new Thread(() -> {
while (!initFlag) {
//System.out.println("runing");
counter++;
}
log.info("线程:" + Thread.currentThread().getName()
+ "当前线程嗅探到initFlag的状态的改变");
}, "threadA");
threadA.start();

// 中间休眠500hs
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}

// 线程B
Thread threadB = new Thread(() -> {
refresh();
}, "threadB");
threadB.start();
}
}

结合前面介绍的数据同步八大原子操作,我们来分析下:

线程A启动后:

  • 第一步:执行read操作,作用于主内存,将变量initFlag从主内存拷贝一份,这时候还没有放到工作内存中,而是放在了总线里。如下图
  • 第二步:执行load操作,作用于工作内存,将上一步拷贝的变量,放入工作内存中;
  • 第三步:执行use(使用)操作,作用于工作内存,把工作内存中的变量传递给执行引擎,对于线程A来说,执行引擎会判断initFlag = true吗?不等于,循环一直进行

执行过程如下图:

线程B启动后:

  • 第一步:执行read操作,作用于主内存,从主内存拷贝initFlag变量,这时候拷贝的变量还没有放到工作内存中,这一步是为了load做准备;
  • 第二步:执行load操作,作用于工作内存,将拷贝的变量放入到工作内存中;
  • 第三步:执行use操作,作用于工作内存,将工作内存的变量传递给执行引擎,执行引擎判断while(!initFlag),那么执行循环体;
  • 第四步:执行assign操作,作用于工作内存,把从执行引擎接收的值赋值给工作内存的变量,即设置 inifFlag = true ;
  • 第五步:执行store操作,作用于工作内存,将工作内存中的变量 initFlag = true 传递给主内存;
  • 第六步:执行write操作,作用于工作内存,将变量写入到主内存中。

volatile 无法保证原子性

1
2
3
4
5
6
7
java复制代码//示例
public class VolatileVisibility {
    public static volatile int i =0;
    public static void increase(){
    i++;
    }
}

在并发场景下, i 变量的任何改变都会立马反应到其他线程中,但是如此存在多线程同时调用 increase() 方法的化,就会出现线程安全问题,毕竟 i++ 操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两部完成。如果第二个线程在第一个线程读取旧值和写回新值期间读取 i 的值,那么第二个线程就会于第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于 increase 方法必须使用 synchronized 修饰,以便保证线程安全,需要注意的是一旦使用 synchronized 修饰方法后,由于 sunchronized 本身也具备于 volatile 相同的特性,即可见性,因此在这样的情况下就完全可以省去 volatile 修饰变量。

案例:起了10个线程,每个线程加到1000,10个线程,一共是10000

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
java复制代码package com.niuh.jmm;

/**
* volatile可以保证可见性, 不能保证原子性
*/
public class Jmm04_CodeAtomic {

private volatile static int counter = 0;
static Object object = new Object();

public static void main(String[] args) {

for (int i = 0; i < 10; i++) {
Thread thread = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
synchronized (object) {
counter++;//分三步- 读,自加,写回
}
}
});
thread.start();
}

try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println(counter);

}
}

而实际结果,不到10000, 原因是: 有并发操作.

这时候, 如果我在counter上加关键字volatile, 可以保证原子性么?

1
java复制代码private volatile static int counter = 0;

我们发现, 依然不是10000, 这说明volatile不能保证原子性.

每个线程, 只有一个操作, counter++, 为什么不能保证原子性呢?

其实counter++不是一步完成的. 他是分为多步完成的. 我们用下面的图来解释

线程A通过read, load将变量加载到工作内存, 通过user将变量发送到执行引擎, 执行引擎执行counter++,这时线程B启动了, 通过read, load将变量加载到工作内存, 通过user将变量发送到执行引擎, 然后执行复制操作assign, stroe, write操作. 我们看到这是经过了n个步骤. 虽然看起来就是简单的一句话.

当线程B执行store将数据回传到主内存的时候, 同时会通知线程A, 丢弃counter++, 而这时counter已经自加了1, 将自加后的counter丢掉, 就导致总数据少1.

volatile 禁止重排优化

volatile 关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,关于指令重排优化前面已经分析过,这里主要简单说明一下 volatile 是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)

硬件层的内存屏障

Intel 硬件提供了一系列的内存屏障,主要又:

  1. lfence,是一种 Load Barrier 读屏障;
  2. sfence,是一种 Store Barrier 写屏障;
  3. mfence,是一种全能型的屏障,具备 lfence 和 sfence 的能力;
  4. Lock 前缀,Lock 不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock 会对 CPU总线和高速缓存加锁,可以理解为 CPU 指令级的一种锁。它后面可以跟 ADD、ADC、AND、BTC、BTR、BTS、CMPXCHG、CMPXCH8B、DEC、INC、NEG、NOT、OR、SBB、SUB、XOR、XADD、and XCHG 等指令。

JVM的内存屏障

不同硬件实现内存屏障的方式不同,Java 内存模型屏蔽了这些底层硬件平台的差异,由 JVM 来为不同平台生产相应的机器码。JVM中提供了四类内存屏障指令:

指令示例 说明
Load1;LoadLoad;Load2 保证load1的读取操作和load2及后续读取操作之前执行
Store1;StoreStore;Store2 在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存
Load1;LoadStore;Store2 在store2及其后的写操作执行前,保证load1的读操作已经读取结束
Store1;StoreLoad;Load2 在store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行

内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个:

  1. 一是保证特定操作的执行顺序;
  2. 二是保证某些变量的内存可见性(利用该特性实现 volatile 的内存可见性)。

由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条 Memory Barrier 则会高速编译器和CPU,不管什么指令都不能和这条 Memory Barrier 指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。

Memory Barrier 的另外一个作用是强制刷出各种 CPU 的缓存数据,因此任何 CPU 上的线程都能读取到这些数据的最新版本。

总之,volatile 变量正是通过内存屏障实现其内存中的语义,即可见性和禁止重排优化。

下面看一个非常典型的禁止重排优化的例子DCL,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码public class DoubleCheckLock {
private volatile static DoubleCheckLock instance;
private DoubleCheckLock(){}
public static DoubleCheckLock getInstance(){
//第一次检测
if (instance==null){
//同步
synchronized (DoubleCheckLock.class){
if (instance == null){
//多线程环境下可能会出现问题的地方
instance = new  DoubleCheckLock();
}
}
}
return instance;
}
}

上述代码一个经典的单例的双重检测的代码,这段代码在单线程环境下并没什么问题,但如果在多线程环境下就可能会出现线程安全的问题。因为在于某一线程执行到第一次检测,读取到 instance 不为 null 时,instance 的引用对象可能还没有完成初始化。

关于 单例模式 可以查看《设计模式系列 — 单例模式》

因为 instance = new DoubleCheckLock(); 可以分为以下3步完成(伪代码)

1
2
3
java复制代码memory = allocate(); // 1.分配对象内存空间
instance(memory); // 2.初始化对象
instance = memory; // 3.设置instance指向刚分配的内存地址,此时instance != null

由于步骤1 和步骤2 间可能会重排序,如下:

1
2
3
java复制代码memory=allocate();//1.分配对象内存空间
instance=memory;//3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
instance(memory);//2.初始化对象

由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的指向结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问 instance 不为 null 时,由于 instance 实例未必已经初始化完成,也就造成来线程安全问题。那么该如何解决呢,很简单,我们使用 volatile 禁止 instance 变量被执行指令重排优化即可。

1
2
java复制代码//禁止指令重排优化
private volatile static DoubleCheckLock instance;

volatile 内存语义的实现

前面提到过重排序分为编译器重排序和处理器重排序。为来实现 volatile 内存语义,JMM 会分别限制这两种类型的重排序类型。

下面是JMM针对编译器制定的 volatile 重排序规则表。

第一个操作 第二个操作:普通读写 第二个操作:volatile读 第二个操作:volatile写
普通读写 可以重排 可以重排 不可以重排
volatile读 不可以重排 不可以重排 不可以重排
volatile写 可以重排 不可以重排 不可以重排

举例来说,第二行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或者写时,如果第二个操作为 volatile 写,则编译器不能重排序这两个操作。

从上图可以看出:

  • 当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保了 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。
  • 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保了 volatile 读之后的操作不会被编译器重排序到 volatie 读之前。
  • 当第一个操作是 volatile 写,第二个操作是 volatile 读或写时,不能重排序。

为了实现 volatile 的内存语义,编译在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM 采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。

  • 在每个volatile写操作的前面插入一个StoreStore屏障;
  • 在每个volatile写操作的后面插入一个StoreLoad屏障;
  • 在每个volatile读操作的后面插入一个LoadLoad屏障;
  • 在每个volatile读操作的后面插入一个LoadStore屏障;

上述内存屏障插入策略非常保守,但它可以保证在任一处理器平台,任意的程序中都能得到正确的 volatile 内存语义。

下面是保守策略下,volatile 写插入内存屏障后生成的指令序列示意图

上图中 StoreStore 屏障可以保证在volatile 写之前,其前面的所有普通写操作已经对任意处理器可见来。这是因为StoreStore屏障将保障上面所有的普通写在 volatile 写之前刷新到主内存。

这里比较有意思的是,volatile 写后面的 StoreLoad 屏障。此屏障的作用是避免 volatile 写与后面可能有的 volatile 读/写操作重排序。因为编译器常常无法准确判断在一个 volatile 写的后面十分需要插入一个 StoreLoad 屏障(比如,一个volatile写之后方法立即return)。为来保证能正确实现 volatile 的内存语义,JMM 在采取了保守策略:在每个 volatile 写的后面,或者每个 volatile 读的前面插入一个 StoreLoad 屏障。从整体执行效率的角度考虑,JMM最终选择了在每个 volatile 写的后面插入一个 StoreLoad 屏障,因为volatile写-读内存语义的常见使用模式是:一个写线程写 volatile 变量,多个线程读同一个 volatile 变量。当读线程的数量大大超过写线程时,选择在 volatile 写之后插入 StoreLoad 屏障将带来可观的执行效率的提升。从这里可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率。

下图是在保守策略下,volatile 读插入内存屏障后生成的指令序列示意图

上图中 LoadLoad 屏障用来禁止处理器把上面的 volatile读 与下面的普通读重排序。LoadStore 屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。

上述 volatile写 和 volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

下面通过具体的示例代码进行说明。

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码class VolatileBarrierExample {
       int a;
       volatile int v1 = 1;
       volatile int v2 = 2;
       void readAndWrite() {
           int i = v1;      // 第一个volatile读
           int j = v2;       // 第二个volatile读
           a = i + j;         // 普通写
           v1 = i + 1;       // 第一个volatile写
v2 = j * 2;       // 第二个 volatile写
       }
}

针对 readAndWrite() 方法,编译器在生成字节码时可以做如下的优化。

注意,最后的 StoreLoad 屏障不能省略。因为第二个 volatile 写之后,方法立即 return。此时编译器可能无法准确判断断定后面十分会有 volatile 读或写,为了安全起见,编译器通常会在这里插入一个 StoreLoad 屏障。

上面的优化针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以X86处理完为例,上图中除最后的 StoreLoad 屏障外,其他的屏障都会被省略。

前面保守策略下的 volatile 读和写,在 X86 处理器平台可以优化如下图所示。X86处理器仅会对读-写操作做重排序。X86 不会对读-读、读-写 和 写-写 做重排序,因此在 X86 处理器中会省略掉这3种操作类型对应的内存屏障。在 X86 中,JMM仅需在 volatile 写后面插入一个 StoreLoad 屏障即可正确实现 volatile写-读的内存语义,这意味着在 X86 处理器中,volatile写的开销比volatile读的开销会大很多(因为执行StoreLoad的屏障开销会比较大)。

参考资料

  • 《并发编程的艺术》

PS:以上代码提交在 Github :github.com/Niuh-Study/…

文章持续更新,可以公众号搜一搜「 一角钱技术 」第一时间阅读, 本文 GitHub org_hejianhui/JavaStudy 已经收录,欢迎 Star。

本文转载自: 掘金

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

一次打包引发的思考,原来maven还能这么玩~

发表于 2020-11-09

前言

昨天有一个读者找我的交流工作心得,偶然间提到一个有趣的问题,如下:

「大致的意思」:公司最近在整多模块开发,由于模块之间相互依赖,每次打包都很烦,必须根据依赖关系逐一进行打包,有没有省事的办法呢?

其实玩转Maven的朋友都知道,只需要一条命令即可解决问题。

依赖关系

假设有一个多模块项目,父工程P中含有三个子模块A、B、C,三个模块有如下的依赖关系:

  1. A 依赖 B、C。
  2. B 依赖 C。

依赖关系图

依赖关系图

父工程P的pom.xml如下:

1
2
3
4
5
6
7
复制代码.....
<modules>
<module>A</module>
<module>B</module>
<module>C</module>
</modules>
.....

A模块的pom.xml如下:

1
2
3
4
5
6
7
复制代码....
<dependency>
<groupId>xxx.xxxx</groupId>
<artifactId>B</artifactId>
<version>xxxx</version>
</dependency>
.....

B模块的pom.xml如下:

1
2
3
4
5
6
7
复制代码....
<dependency>
<groupId>xxx.xxxx</groupId>
<artifactId>C</artifactId>
<version>xxxx</version>
</dependency>
.....

C模块的pom.xml如下:

1
复制代码....

你会怎么做?

现在产品需要上线项目A,你该如何打包?

最容易想到的则是分开打包,分别执行如下的命令:

1
2
3
复制代码mvn clean install C
mvn clean install B
mvn clean package A

以上三个模块轮流打包,至少需要五分钟以上吧,你不慌吗?

慌的一批

慌的一批

重点来了,我只需要如下一条命令即可打包完成:

1
复制代码mvn clean package -pl A -am -P test -DskipTests=true

以上命令有什么高深的吗?-P指定环境,-DskipTests=true跳过测试,但是-pl和-am是什么?

答案肯定是在-pl和-am这两个参数了。

必知的几个参数

从以上的例子中可以知道重要的就是-pl和-am这两个参数,那么是什么意思呢?如下:

参数 说明
-pl 可选,指定需要处理的工程,多个使用英文逗号分隔,取值是artifactId
-am 可选,同时处理 pl参数 指定模块的依赖模块
-amd 可选,同时处理依赖于 pl参数 指定模块的模块
-N 可选,表示不递归子模块

怎么样,理解了吗?是不是有点晦涩难懂,哈哈….

what?

what?

别着急,下面通过几个命令理解一下(全部在父工程P的根目录下执行)。

  1. mvn clean install -pl A -am

对父工程P、子模块A以及A模块依赖的B、C模块执行mvn clean install操作。

这个命令执行成功后,可以看到P、A、B、C四个模块全部安装到本地了。

  1. mvn clean install -pl C -am

对父工程P、子模块C模块执行mvn clean install操作。

这个命令执行成功后,可以看到P、C两个模块安装到本地。

由于C模块「不依赖」其他的两个子模块,因此A、B模块不会执行相关命令。

  1. mvn clean install -pl C -amd

对父工程P、子模块C以及依赖于C模块的B、C模块执行mvn clean install`操作。

这个命令执行成功后,可以看到P、A、B、C四个模块全部安装到本地了。

  1. mvn clean install -N

只会打包父工程P,它的子模块将不会执行相关操作。

怎么样?通过以上的命令应该理解了吧。

总结

随着项目的体量逐渐增长,可不止上面的几个模块,学会以上几个命令,提升的效率可不止一倍啊。

本文转载自: 掘金

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

NDK 说说 so 库从加载到卸载的全过程

发表于 2020-11-09

点赞关注,不再迷路,你的支持对我意义重大!

🔥 Hi,我是丑丑。本文 GitHub · Android-NoteBook 已收录,这里有 Android 进阶成长路线笔记 & 博客,欢迎跟着彭丑丑一起成长。(联系方式在 GitHub)

前言

  • 在 JNI 开发中,必然需要用到 so 库,那么你清楚 so 库从加载到卸载的全过程吗?
  • 在这篇文章里,我将带你建立对 so 库从加载进内存到卸载整个过程的理解。另外,文末的应试建议也不要错过哦,如果能帮上忙,请务必点赞加关注,这真的对我非常重要。

相关文章

  • 《NDK | 说说 so 库从加载到卸载的全过程》
  • 《NDK | 带你梳理 JNI 函数注册的方式和时机》
  • 《NDK | 带你探究 getProperty() 获取系统属性原理》
  • 《NDK | 一篇文章带你点亮 JNI 开发基石符文》(快写好了)
  • 《NDK | 一篇文章开启你的 NDK 技能树》(真的快写好了)

目录


  1. 获取 so 库

关于 获取 so 库的具体步骤,我在这篇文章里讨论,《NDK | 一篇文章开启你的 NDK 技能树》,请关注。通常来说,最终生成的 so 库命名为lib[name].so,例如系统内置的 so 库:


  1. 加载 so 库

首先,让我们看看加载 so 库的入口,加载动态库需要使用System.load(...) 或 System.loadLibrary(...)。通常来说,都会放在static {}中执行。

System.java

1
2
3
4
5
6
7
8
9
scss复制代码public static void load(String filename) {
1. 委派给 Runtime#load0(...)
Runtime.getRuntime().load0(VMStack.getStackClass1(), filename);
}

public static void loadLibrary(String libname) {
2. 委派给 Runtime#loadLibrary0(...)
Runtime.getRuntime().loadLibrary0(VMStack.getCallingClassLoader(), libname);
}

其中,getCallingClassLoader()返回的是加载调用者使用的 ClassLoader。

2.1 Runtime#load0(…) 源码分析

Runtime.java

1
2
3
4
5
6
7
8
9
10
11
12
13
scss复制代码-> 1(已简化)
synchronized void load0(Class<?> fromClass, String filename) {
1.1 检查是否为绝对路径
if (!(new File(filename).isAbsolute())) {
throw new UnsatisfiedLinkError("Expecting an absolute path of the library: " + filename);
}

1.2 调用 nativeLoad(【绝对路径】) 加载动态库
String error = nativeLoad(filename, fromClass.getClassLoader());
if (error != null) {
throw new UnsatisfiedLinkError(error);
}
}

可以看到,Runtime#load0(...)的逻辑比较简单:

  • 1.1 确保参数filename是一个绝对路径
  • 1.2 调用nativeLoad(【绝对路径】)加载动态库,这个方法我在 第 3 节 nativeLoad(…) 主流程源码分析 说。

2.2 Runtime#loadLibrary0(…) 源码分析

Runtime.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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
ini复制代码-> 2(已简化)
synchronized void loadLibrary0(ClassLoader loader, String libname) {
2.1 检查是否出现路径分隔符
if (libname.indexOf((int)File.separatorChar) != -1) {
throw new UnsatisfiedLinkError("Directory separator should not appear in library name: " + libname);
}

String libraryName = libname;
2.2 ClassLoader 非空

if (loader != null) {
2.2.1 根据动态库名称查询动态库的绝对路径
String filename = loader.findLibrary(libraryName);
if (filename == null) {
throw new UnsatisfiedLinkError(...);
}

2.2.2 调用 nativeLoad(【绝对路径】) 加载动态库
String error = nativeLoad(filename, loader);
if (error != null) {
throw new UnsatisfiedLinkError(error);
}
return;
}

2.3 ClassLoader 为空(丑丑也不知道什么场景会为空)

2.3.1 拼接 lib 前缀与.so 后缀
String filename = System.mapLibraryName(libraryName);
List<String> candidates = new ArrayList<String>();

2.3.2 遍历每个 so 库存储路径
String lastError = null;
for (String directory : getLibPaths()) {
String candidate = directory + filename;
candidates.add(candidate);
2.3.3 调用 nativeLoad(【绝对路径】) 加载动态库
String error = nativeLoad(candidate, loader);
if (error == null) {
return
}
}
throw new UnsatisfiedLinkError(...);
}

可以看到,Runtime#loadLibrary0(...) 主要分为 ClassLoader 为非空与为空两种情况。

先看 ClassLoader 非空的情况:

  • 2.2.1 调用ClassLoader#findLibrary(libraryName)查询动态库的绝对路径,这个方法我后文再说。
  • 2.2.2 调用nativeLoad(【绝对路径】)加载动态库

再看下 ClassLoader 为空的情况(一般不会):

System.java

1
2
arduino复制代码-> 2.3.1
public static native String mapLibraryName(String libname);

System.c

1
2
3
4
5
kotlin复制代码JNIEXPORT jstring JNICALL
System_mapLibraryName(JNIEnv *env, jclass ign, jstring libname) {
1、libname 拼接 JNI_LIB_PREFIX(lib) 前缀
2、libname 拼接 JNI_LIB_SUFFIX(.so) 后缀
}

jvm_md.h

1
2
arduino复制代码#define JNI_LIB_PREFIX "lib"
#define JNI_LIB_SUFFIX ".so"

Runtime.java

1
2
3
4
5
6
ini复制代码-> 2.3.2(已简化,源码基于 DCL 单例)
private String[] getLibPaths() {
String javaLibraryPath = System.getProperty("java.library.path");
String[] paths = javaLibraryPath.split(":");
return paths;
}
  • 2.3.1 调用 native 方法System.mapLibraryName(),拼接 lib 前缀与.so 后缀
  • 2.3.2 调用System.getProperty("java.library.path")获取系统 so 库存储路径
  • 2.3.3 遍历每个 so 库存储路径,拼接除动态库的绝对路径,调用nativeLoad(【绝对路径】)加载动态库

关于 System.getProperty("java.library.path") 的源码分析,在我之前写过的一篇文章里讲过:《NDK | 带你探究 getProperty() 获取系统属性原理》,这里我简单复述一下:

1、"java.library.path"这个属性是由运行环境管理的;

2、对于 64 位系统,返回的是"/system/lib64" 、 "/vendor/lib64";

3、对于 32 位系统,返回的是"/system/lib" 、 "/vendor/lib"。

可以看到,对于 ClassLoader 非空和为空两种情况,其实最后都需要调用nativeLoad(【绝对路径】)加载动态库,这其实和Runtime#load0(...)的逻辑一致。这个方法我在 第 3 节 nativeLoad(…) 主流程源码分析 说。

2.3 ClassLoader#findLibrary(libraryName) 源码分析

对了,在前面讲到 ClassLoader 非空的情况时,ClassLoader#findLibrary(libraryName)还没有分析,现在讲下。在 Android 系统中,ClassLoader 通常是 PathClassLoader:

PathClassLoader.java

1
2
3
4
5
6
7
8
9
scala复制代码public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super(dexPath, null, null, parent);
}

public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
super(dexPath, null, librarySearchPath, parent);
}
}

BaseDexClassLoader.java

1
2
3
4
5
6
7
8
9
10
11
12
scala复制代码public class BaseDexClassLoader extends ClassLoader {
public BaseDexClassLoader(String dexPath, File optimizedDirectory, String librarySearchPath, ClassLoader parent, boolean isTrusted) {
super(parent);
this.pathList = new DexPathList(this, dexPath, librarySearchPath, null, isTrusted);
}

public String findLibrary(String name) {
return pathList.findLibrary(name);
}

...
}

PathClassLoader 没用重写findLibrary(),所以主要的逻辑还是在 BaseDexClassLoader 中,最终是委派给 DexPathList 处理的:

DexPathList.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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
arduino复制代码-> 2.2.1 根据动态库名称查询动态库的绝对路径
public String findLibrary(String libraryName) {
1、拼接 lib 前缀与.so 后缀
String fileName = System.mapLibraryName(libraryName);
2、遍历 nativeLibraryPathElements 路径
for (NativeLibraryElement element : nativeLibraryPathElements) {
3、搜索目标 so 库
String path = element.findNativeLibrary(fileName);
if (path != null) {
return path;
}
}
return null;
}

NativeLibraryElement[] nativeLibraryPathElements;
private Element[] dexElements;
private final List<File> nativeLibraryDirectories;
private final List<File> systemNativeLibraryDirectories;

public DexPathList(ClassLoader definingContext, String dexPath, String librarySearchPath, File optimizedDirectory) {
this(definingContext, dexPath, librarySearchPath, optimizedDirectory, false);
}

0、 初始化
DexPathList(ClassLoader definingContext, String dexPath, String librarySearchPath, File optimizedDirectory, boolean isTrusted) {
...
所有 Dex 文件
this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptions, definingContext, isTrusted);

app 目录的 so 库路径
this.nativeLibraryDirectories = splitPaths(librarySearchPath, false);

系统的 so 库路径("java.library.path"))
this.systemNativeLibraryDirectories = splitPaths(System.getProperty("java.library.path"), true);

记录 app 和系统的 so 库路径
List<File> allNativeLibraryDirectories = new ArrayList<>(nativeLibraryDirectories);
allNativeLibraryDirectories.addAll(systemNativeLibraryDirectories);
this.nativeLibraryPathElements = makePathElements(allNativeLibraryDirectories);

...
}

可以看到,DexPathList#findLibrary(...)主要分为 3 个步骤:

  • 1、拼接 lib 前缀与.so 后缀
  • 2、遍历nativeLibraryPathElements路径
  • 3、搜索目标 so 库,如果存在,返回拼接后的绝对路径

其中nativeLibraryPathElements路径由两部分组成:

  • 1、app 目录下的 so 库路径(/data/app/[packagename]/lib/arm64)
  • 2、系统 so 库存储路径(/system/lib64、/vendor/lib64)

Native libraries may exist in both the system and application library paths, and we use this search order:

  1. This class loader’s library path for application librarie (librarySearchPath):

1.1. Native library directories

1.2. Path to libraries in apk-files

  1. The VM’s library path from the system property for system libraries also known as java.library.path

2.4 小结

最后,总结System.load(...)或System.loadLibrary(...)的异同:

不同点:

  • System.load(...)指定的是 so 库的绝对路径,只会在该路径搜索 so 库;
  • System.loadLibrary(...)指定的是 so 库的名称,查找时会自动拼接 lib 前缀和 .so 后缀,并在 app 路径和系统路径搜索。

共同点:

  • 两个方法最终都得到一个绝对路径,并调用 native 方法 nativeLoad(【绝对路径】)加载动态库。

到目前为止,调用栈如下:

1
2
3
4
5
6
7
8
scss复制代码System.loadLibrary(libPath)
-> Runtime.load0(libPath)
-> nativeLoad(libPath)

System.loadLibrary(libName)
-> Runtime.loadLibrary0(libNane)
-> ClassLoader#findLibrary(libName)-> DexPathList#findLibrary(libName)
-> nativeLoad(libPath)


  1. nativeLoad(…) 主流程源码分析

经过前面的分析,取到 so 库的绝对路径之后,最终是调用 native 方法nativeLoad(...)加载 so 库,相关源码如下:

Runtime.java

1
2
arduino复制代码-> 1.2 / 2.2.2 / 2.3.3
private static native String nativeLoad(String filename, ClassLoader loader);

Runtime.c

1
2
3
4
kotlin复制代码JNIEXPORT jstring JNICALL
Runtime_nativeLoad(JNIEnv* env, jclass ignored, jstring javaFilename, jobject javaLoader) {
return JVM_NativeLoad(env, javaFilename, javaLoader);
}

最终调用到:java_vm_ext.cc

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
ini复制代码共享库列表
std::unique_ptr<Libraries> libraries_;

已简化
bool JavaVMExt::LoadNativeLibrary(JNIEnv* env,
const std::string& path,
jobject class_loader,
std::string* error_msg) {
SharedLibrary* library;
Thread* self = Thread::Current();

1、检查是否已经加载过
library = libraries_->Get(path);

2、已经加载过,跳过
if (library != nullptr) {
...
return true;
}

3、调用 dlopen 打开 so 库
void* handle = dlopen(path,RTLD_NOW);

4、创建共享库
std::unique_ptr<SharedLibrary> new_library(
new SharedLibrary(env,
self,
path,
handle,
needs_native_bridge,
关注点:共享库中持有 ClassLoader(卸载 so 库时用到)
class_loader,
class_loader_allocator));

5、将共享库记录到 libraries_ 表中
libraries_->Put(path, library);

6、调用 so 库中的 JNI_OnLoad 方法
void* sym = dlsym(library,"JNI_OnLoad");
typedef int (*JNI_OnLoadFn)(JavaVM*, void*);
JNI_OnLoadFn jni_on_load = reinterpret_cast<JNI_OnLoadFn>(sym);
int version = (*jni_on_load)(this, nullptr);

return true
}

上面的代码已经非常简化了,主要关注以下几点:

  • 1、检查是否已经加载过(libraries_记录了已经加载过的 so 库);
  • 2、如果已经加载过,跳过;
  • 3、调用dlopen打开 so 库;
  • 4、创建共享库SharedLibrary,这个就是 so 库的内存表示,需要注意的是,SharedLibrary 和 ClassLoader 是有关联的(SharedLibrary 持有了 ClassLoader),这一点在卸载 so 库的时候会用到;
  • 5、将共享库记录到libraries_表中;
  • 6、调用 so 库中的JNI_OnLoad方法,返回值是jint类型,告诉虚拟机此 so 库使用的 JNI版本

整个加载的过程:


  1. 卸载 so 库

JDK 没有提供直接卸载 so 库的方法,而是 在ClassLoader 卸载时跟随卸载,具体触发的地方在虚拟机堆执行垃圾回收的源码:

heap.cc

1
2
3
4
5
6
rust复制代码collector::GcType Heap::CollectGarbageInternal(collector::GcType gc_type,
GcCause gc_cause,
bool clear_soft_references) {
...
soa.Vm()->UnloadNativeLibraries();
}

这里我们只关注与共享库有关的代码,最终调用到:java_vm_ext.cc

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
ini复制代码已简化
void UnloadNativeLibraries(){
1、遍历共享库列表 libraries_
for (auto it = libraries_.begin(); it != libraries_.end(); ) {
SharedLibrary* const library = it->second;

2、检查关联的 ClassLoader 是否卸载(unload)
const jweak class_loader = library->GetClassLoader();
if (class_loader != nullptr && self->IsJWeakCleared(class_loader)) {

3、记录需要卸载的共享库
unload_libraries.push_back(library);
it = libraries_.erase(it);
} else {
++it;
}
}
4、遍历需要卸载的共享库,执行 JNI_OnUnloadFn()
typedef void (*JNI_OnUnloadFn)(JavaVM*, void*);
for (auto library : unload_libraries) {
void* const sym = dlsym(library, "JNI_OnUnload")
JNI_OnUnloadFn jni_on_unload = reinterpret_cast<JNI_OnUnloadFn>(sym);
jni_on_unload(self->GetJniEnv()->GetVm(), nullptr);

5、回收内存
delete library;
}
}

上面的代码已经非常简化了,主要关注以下几点:

  • 1、遍历共享库列表libraries_
  • 2、检查关联的 ClassLoader 是否卸载(unload)
  • 3、记录需要卸载的共享库
  • 4、遍历需要卸载的共享库,执行JNI_OnUnload(),返回值是void
  • 5、回收内存

  1. 总结

  • 应试建议
    1、应知晓 so 库加载到卸载的大体过程,主要分为:确定 so 库绝对路径、nativeLoad 加载进内存、ClassLoader 卸载时跟随卸载;

2、应知晓搜索 so 库的路径,分为 App 路径和系统路径

3、应知晓JNI_OnLoad 与JNI_OnUnLoad 的执行时机(分别在加载与卸载时执行)


参考资料

  • 《Java中System.loadLibrary() 的执行过程》 —— WolfCS 著
  • 《Android JNI 原理分析》 —— Gityuan 著
  • 《loadLibrary 动态库加载过程分析》 —— Gityuan 著

推荐阅读

  • 密码学 | Base64是加密算法吗?
  • 算法面试题 | 回溯算法解题框架
  • 算法面试题 | 链表问题总结
  • Java | 带你理解 ServiceLoader 的原理与设计思想
  • 计算机网络 | 图解 DNS & HTTPDNS 原理
  • Android | 说说从 android:text 到 TextView 的过程
  • Android | 面试必问的 Handler,你确定不看看?
  • Android | 带你探究 LayoutInflater 布局解析原理
  • Android | View & Fragment & Window 的 getContext() 一定返回 Activity 吗?

感谢喜欢!你的点赞是对我最大的鼓励!欢迎关注彭旭锐的GitHub!

本文转载自: 掘金

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

StopWatch —— 让 Spring 来帮你计算接口时

发表于 2020-11-09

微信搜索 程序员的起飞之路 可以加我公众号,保证一有干货就更新~

(回复关键字“资料”可以获取小弟多年精华,懂的都懂~)

是否还为接口计时而烦恼?

是否还在无脑的复制 System.currentTimeMillis() ?

是否还在为定位“慢代码瓶颈”而苦苦思索?

我为大家来介绍一个神器 —— StopWatch!让 Spring 来帮你统计时间吧!

一、背景

相信大家肯定遇到过我开头提到过的几种问题吧。也相信各位一定写过如下重复无意义的计时代码吧。当一段代码耗时极长,并且调用接口众多时,我们就不得不去分步统计看到底是哪个接口拖后腿,并以此定位接口性能瓶颈在哪里。那有如下代码就不奇怪了,我常常在想,是否有一套工具来帮我们统计接口耗时、占比,帮我们分析慢接口、慢调用呢?直到我遇到了他——StopWatch!

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
java复制代码    public static void func1() throws InterruptedException {
long start = System.currentTimeMillis();
System.out.println("phase1 do something....");
Thread.sleep(1000);
long phase1 = System.currentTimeMillis();
System.out.printf("phase1 cost time %d ms\n", (phase1 - start));

System.out.println("phase2 do something....");
Thread.sleep(2000);
long phase2 = System.currentTimeMillis();
System.out.printf("phase2 cost time %d ms\n", (phase2 - phase1));

System.out.println("phase3 do something....");
Thread.sleep(3000);
long phase3 = System.currentTimeMillis();
System.out.printf("phase3 cost time %d ms\n", (phase3 - phase2));

System.out.println("phase4 do something....");
Thread.sleep(4000);
long phase4 = System.currentTimeMillis();
System.out.printf("phase4 cost time %d ms\n", (phase4 - phase3));

long end = System.currentTimeMillis();
System.out.printf("func1 cost %d ms\n", (end - start));

}

二、初遇

初遇 StopWatch 是同事跟我讲,说有个东西可以替代你这里的 end - start 代码。我抱着不屑一顾的态度去看了一眼他的代码,看到了 StopWatch ,追进源码大致一看,这归根结底不还是用了 System.nanoTime 去减了一下,跟我的有什么区别呀。当然,如果故事终结于此,也就不会有这篇博客了。让我决定深追下去的只有一个原因,这个东西是 Spring 家的,而且 Spring 用了他去做接口时间的统计如图所示:

application

由于我个人对 Spring 的盲目崇拜,觉得他用啥都是好的!我决定好好研究一下这个 StopWatch,事实证明,真香!

三、深究

3.1 使用

对一切事物的认知,都是从使用开始,那就先来看看它的用法。开头一段的代码,在替换成 StopWatch 后,会如下所示:

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
java复制代码    public static void func2() throws InterruptedException {
StopWatch stopWatch = new StopWatch("func2");

stopWatch.start("phase1");
System.out.println("phase1 do something....");
Thread.sleep(1000);
stopWatch.stop();
System.out.printf("phase1 cost time %d ms\n", stopWatch.getLastTaskTimeMillis());

stopWatch.start("phase2");
System.out.println("phase2 do something....");
Thread.sleep(2000);
stopWatch.stop();
System.out.printf("phase2 cost time %d ms\n", stopWatch.getLastTaskTimeMillis());

stopWatch.start("phase3");
System.out.println("phase3 do something....");
Thread.sleep(3000);
stopWatch.stop();
System.out.printf("phase3 cost time %d ms\n", stopWatch.getLastTaskTimeMillis());

stopWatch.start("phase4");
System.out.println("phase4 do something....");
Thread.sleep(4000);
stopWatch.stop();
System.out.printf("phase4 cost time %d ms\n", stopWatch.getLastTaskTimeMillis());

System.out.printf("func1 cost %d ms\n", stopWatch.getTotalTimeMillis());
System.out.println("stopWatch.prettyPrint() = " + stopWatch.prettyPrint());

}

乍一眼看上去,是不是觉得这代码反而比之前的还要多了。但是如果实际写起来,实际上是要比第一种好写许多的。只管控制每段代码统计的开始和结束即可,不用关心是哪个时间减哪个时间。执行结果更为喜人:

执行结果

如图所示,StopWatch 不仅正确记录了上个任务的执行时间,并且在最后还可以给出精确的任务执行时间(纳秒级别)和耗时占比。其实并不止于此,StopWatch 还可以记录整个任务的走向流程,例如走过了哪几个任务,各个耗时都是可以通过方法拿到的。例如:

1
2
3
4
5
6
7
8
java复制代码System.out.println("stopWatch.getLastTaskName() = " + stopWatch.getLastTaskName());
System.out.println("stopWatch.getLastTaskInfo().getTimeMillis() = " + stopWatch.getLastTaskInfo().getTimeMillis());

Arrays.stream(stopWatch.getTaskInfo()).forEach(e->{
System.out.println("e.getTaskName() = " + e.getTaskName());
System.out.println("e.getTimeMillis() = " + e.getTimeMillis());
System.out.println("---------------------------");
});

执行结果如下:

执行结果1

这个链路和信息目前看上去可能没什么用,在后面的扩展章节我会说明

3.2 源码

老规矩,由浅入深。看完用法,我们来看看源码。先看下组成 StopWatch 的属性

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
java复制代码	/**
* Identifier of this stop watch.
* Handy when we have output from multiple stop watches
* and need to distinguish between them in log or console output.
* 本实例的唯一 Id,用于在日志或控制台输出时区分的。
*/
private final String id;

/**
* 是否保持一个 taskList 链表
* 每次停止计时时,会将当前任务放入这个链表,用以记录任务链路和计时分析
*/
private boolean keepTaskList = true;

/**
* 任务链表
*/
private final List<TaskInfo> taskList = new LinkedList<>();

/** Start time of the current task. */
/** 当前任务的开始时间. */
private long startTimeNanos;

/** Name of the current task. */
/** 当前任务名称. */
@Nullable
private String currentTaskName;

@Nullable
/** 最后一个任务的信息. */
private TaskInfo lastTaskInfo;

/** 任务总数. */
private int taskCount;

/** Total running time. */
/** 总任务时间. */
private long totalTimeNanos;

StopWatch 内部持有一个内部类 TaskInfo,内有两个属性

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
java复制代码	/**
* Nested class to hold data about one task executed within the {@code StopWatch}.
*/
public static final class TaskInfo {

private final String taskName;

private final long timeNanos;

TaskInfo(String taskName, long timeNanos) {
this.taskName = taskName;
this.timeNanos = timeNanos;
}

/**
* Get the name of this task.
*/
public String getTaskName() { return this.taskName; }

/**
* Get the time in nanoseconds this task took.
* @since 5.2
* @see #getTimeMillis()
* @see #getTimeSeconds()
*/
public long getTimeNanos() { return this.timeNanos; }

/**
* Get the time in milliseconds this task took.
* @see #getTimeNanos()
* @see #getTimeSeconds()
*/
public long getTimeMillis() { return nanosToMillis(this.timeNanos); }

/**
* Get the time in seconds this task took.
* @see #getTimeMillis()
* @see #getTimeNanos()
*/
public double getTimeSeconds() { return nanosToSeconds(this.timeNanos); }

}

这里要重点提一下,可能有的读者朋友看到的源码跟我这里贴出来的不一样。这是因为在 Spring 5.2 之前的 StopWatch 中统一使用的毫秒,即 TimeMillis 。而到 Spring 5.2 及其以后的版本,都统一改为纳秒即 TimeNanos 。

看完属性相信大家也差不多猜出来具体的实现了,不过就是维护了一个任务链表,然后开始的时候记一个时间,结束的时候记一个时间,最后取的时候减一下。此处由于篇幅原因就仅放 start 和 stop 两个方法的源码。大家如果对其余的方法感兴趣,请自行查阅源码(spring-core 模块 org.springframework.util.StopWatch 类)。

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
java复制代码	/**
* Start a named task.
* <p>The results are undefined if {@link #stop()} or timing methods are
* called without invoking this method first.
* @param taskName the name of the task to start
* @see #start()
* @see #stop()
*/
public void start(String taskName) throws IllegalStateException {
if (this.currentTaskName != null) {
throw new IllegalStateException("Can't start StopWatch: it's already running");
}
this.currentTaskName = taskName;
// 开始的时候记一个开始时间
this.startTimeNanos = System.nanoTime();
}

/**
* Stop the current task.
* <p>The results are undefined if timing methods are called without invoking
* at least one pair of {@code start()} / {@code stop()} methods.
* @see #start()
* @see #start(String)
*/
public void stop() throws IllegalStateException {
if (this.currentTaskName == null) {
throw new IllegalStateException("Can't stop StopWatch: it's not running");
}
// 结束时计算持续时间,以当前时间减去开始时间,纳秒为单位
long lastTime = System.nanoTime() - this.startTimeNanos;
// 总时长增加
this.totalTimeNanos += lastTime;
// 记录 lastTaskInfo
this.lastTaskInfo = new TaskInfo(this.currentTaskName, lastTime);
// 如果记录任务链表,则将此任务放进链表中
if (this.keepTaskList) {
this.taskList.add(this.lastTaskInfo);
}
++this.taskCount;
this.currentTaskName = null;
}

3.3 拓展

这里想聊一下 StopWatch 的用法,其实上述代码这种方法内定义一个,然后在同一个方法中使用是最基础的用法。下面给大家提供几个思路。(用法不仅限于此,大家可开动脑筋自行开发~)

  1. 在 Controller 层或 Service 层将 StopWatch 放入 ThreadLocal 当中。在整条 Service 调用链中,使用同一个 StopWatch 对象记录方法调用的耗时和路线。最后在结束时生成方法的耗时占比,以定位性能瓶颈。
  2. 利用 Spring-AOP 方式,在每个方法的开始和结束使用 StopWatch 记录接口耗时时间。
  3. 声明一个注解,利用 Spring-AOP 和注解的方式,自定义分析带注解的方法耗时。

在这几种情况下,3.1 章节中所说的任务调用链路就非常重要了。这个调用链路将是定位耗时的非常重要的手段。

四、总结

任何东西都有利害两面,最后我们来看下优点和缺点

优点:

  • 方便统计耗时,通过指定任务名称和耗时占比分析,可以清晰明确的定位到耗时慢的接口和调用。
  • 记录整条任务链路,方便全链路分析。
  • 仅需要关注代码耗时的起始点和终点,无需关注到底需要哪个时间减哪个时间

缺点:

  • 同一个 StopWatch 只能开启一个任务,无法统计 “包含” 类的耗时。例如 A 任务中包含 B 任务这种,只能再开启一个 StopWatch 实例
  • 无法中间插入一个任务,例如 先执行 A 任务,再执行 B 任务,再执行 A 任务。无法将 B 任务的执行时间单独隔离开。

读书越多越发现自己的无知,Keep Fighting!

欢迎友善交流,不喜勿喷~

Hope can help~

我的博客即将同步至 OSCHINA 社区,这是我的 OSCHINA ID:osc_13668000,邀请大家一同入驻:www.oschina.net/sharing-pla…

本文转载自: 掘金

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

高并发,你真的理解透彻了吗? 01 如何理解高并发? 02

发表于 2020-11-07

高并发,几乎是每个程序员都想拥有的经验。原因很简单:随着流量变大,会遇到各种各样的技术问题,比如接口响应超时、CPU load升高、GC频繁、死锁、大数据量存储等等,这些问题能推动我们在技术深度上不断精进。

在过往的面试中,如果候选人做过高并发的项目,我通常会让对方谈谈对于高并发的理解,但是能系统性地回答好此问题的人并不多,大概分成这样几类:

1、对数据化的指标没有概念:不清楚选择什么样的指标来衡量高并发系统?分不清并发量和QPS,甚至不知道自己系统的总用户量、活跃用户量,平峰和高峰时的QPS和TPS等关键数据。

2、设计了一些方案,但是细节掌握不透彻:讲不出该方案要关注的技术点和可能带来的副作用。比如读性能有瓶颈会引入缓存,但是忽视了缓存命中率、热点key、数据一致性等问题。

3、理解片面,把高并发设计等同于性能优化:大谈并发编程、多级缓存、异步化、水平扩容,却忽视高可用设计、服务治理和运维保障。

4、掌握大方案,却忽视最基本的东西:能讲清楚垂直分层、水平分区、缓存等大思路,却没意识去分析数据结构是否合理,算法是否高效,没想过从最根本的IO和计算两个维度去做细节优化。

这篇文章,我想结合自己的高并发项目经验,系统性地总结下高并发需要掌握的知识和实践思路,希望对你有所帮助。内容分成以下3个部分:

  • 如何理解高并发?
  • 高并发系统设计的目标是什么?
  • 高并发的实践方案有哪些?

01 如何理解高并发?

高并发意味着大流量,需要运用技术手段抵抗流量的冲击,这些手段好比操作流量,能让流量更平稳地被系统所处理,带给用户更好的体验。

我们常见的高并发场景有:淘宝的双11、春运时的抢票、微博大V的热点新闻等。除了这些典型事情,每秒几十万请求的秒杀系统、每天千万级的订单系统、每天亿级日活的信息流系统等,都可以归为高并发。

很显然,上面谈到的高并发场景,并发量各不相同,那到底多大并发才算高并发呢?

1、不能只看数字,要看具体的业务场景。不能说10W QPS的秒杀是高并发,而1W QPS的信息流就不是高并发。信息流场景涉及复杂的推荐模型和各种人工策略,它的业务逻辑可能比秒杀场景复杂10倍不止。因此,不在同一个维度,没有任何比较意义。

2、业务都是从0到1做起来的,并发量和QPS只是参考指标,最重要的是:在业务量逐渐变成原来的10倍、100倍的过程中,你是否用到了高并发的处理方法去演进你的系统,从架构设计、编码实现、甚至产品方案等维度去预防和解决高并发引起的问题?而不是一味的升级硬件、加机器做水平扩展。

此外,各个高并发场景的业务特点完全不同:有读多写少的信息流场景、有读多写多的交易场景,那是否有通用的技术方案解决不同场景的高并发问题呢?

我觉得大的思路可以借鉴,别人的方案也可以参考,但是真正落地过程中,细节上还会有无数的坑。另外,由于软硬件环境、技术栈、以及产品逻辑都没法做到完全一致,这些都会导致同样的业务场景,就算用相同的技术方案也会面临不同的问题,这些坑还得一个个趟。

因此,这篇文章我会将重点放在基础知识、通用思路、和我曾经实践过的有效经验上,希望让你对高并发有更深的理解。

02 高并发系统设计的目标是什么?

先搞清楚高并发系统设计的目标,在此基础上再讨论设计方案和实践经验才有意义和针对性。

2.1 宏观目标

高并发绝不意味着只追求高性能,这是很多人片面的理解。从宏观角度看,高并发系统设计的目标有三个:高性能、高可用,以及高可扩展。

1、高性能:性能体现了系统的并行处理能力,在有限的硬件投入下,提高性能意味着节省成本。同时,性能也反映了用户体验,响应时间分别是100毫秒和1秒,给用户的感受是完全不同的。

2、高可用:表示系统可以正常服务的时间。一个全年不停机、无故障;另一个隔三差五出线上事故、宕机,用户肯定选择前者。另外,如果系统只能做到90%可用,也会大大拖累业务。

3、高扩展:表示系统的扩展能力,流量高峰时能否在短时间内完成扩容,更平稳地承接峰值流量,比如双11活动、明星离婚等热点事件。


这3个目标是需要通盘考虑的,因为它们互相关联、甚至也会相互影响。

比如说:考虑系统的扩展能力,你会将服务设计成无状态的,这种集群设计保证了高扩展性,其实也间接提升了系统的性能和可用性。

再比如说:为了保证可用性,通常会对服务接口进行超时设置,以防大量线程阻塞在慢请求上造成系统雪崩,那超时时间设置成多少合理呢?一般,我们会参考依赖服务的性能表现进行设置。

2.2 微观目标

再从微观角度来看,高性能、高可用和高扩展又有哪些具体的指标来衡量?为什么会选择这些指标呢?

2.2.1 性能指标

通过性能指标可以度量目前存在的性能问题,同时作为性能优化的评估依据。一般来说,会采用一段时间内的接口响应时间作为指标。

1、平均响应时间:最常用,但是缺陷很明显,对于慢请求不敏感。比如1万次请求,其中9900次是1ms,100次是100ms,则平均响应时间为1.99ms,虽然平均耗时仅增加了0.99ms,但是1%请求的响应时间已经增加了100倍。

2、TP90、TP99等分位值:将响应时间按照从小到大排序,TP90表示排在第90分位的响应时间, 分位值越大,对慢请求越敏感。


3、吞吐量:和响应时间呈反比,比如响应时间是1ms,则吞吐量为每秒1000次。

通常,设定性能目标时会兼顾吞吐量和响应时间,比如这样表述:在每秒1万次请求下,AVG控制在50ms以下,TP99控制在100ms以下。对于高并发系统,AVG和TP分位值必须同时要考虑。

另外,从用户体验角度来看,200毫秒被认为是第一个分界点,用户感觉不到延迟,1秒是第二个分界点,用户能感受到延迟,但是可以接受。

因此,对于一个健康的高并发系统,TP99应该控制在200毫秒以内,TP999或者TP9999应该控制在1秒以内。

2.2.2 可用性指标

高可用性是指系统具有较高的无故障运行能力,可用性 = 正常运行时间 / 系统总运行时间,一般使用几个9来描述系统的可用性。


对于高并发系统来说,最基本的要求是:保证3个9或者4个9。原因很简单,如果你只能做到2个9,意味着有1%的故障时间,像一些大公司每年动辄千亿以上的GMV或者收入,1%就是10亿级别的业务影响。

2.2.3 可扩展性指标

面对突发流量,不可能临时改造架构,最快的方式就是增加机器来线性提高系统的处理能力。

对于业务集群或者基础组件来说,扩展性 = 性能提升比例 / 机器增加比例,理想的扩展能力是:资源增加几倍,性能提升几倍。通常来说,扩展能力要维持在70%以上。

但是从高并发系统的整体架构角度来看,扩展的目标不仅仅是把服务设计成无状态就行了,因为当流量增加10倍,业务服务可以快速扩容10倍,但是数据库可能就成为了新的瓶颈。

像MySQL这种有状态的存储服务通常是扩展的技术难点,如果架构上没提前做好规划(垂直和水平拆分),就会涉及到大量数据的迁移。

因此,高扩展性需要考虑:服务集群、数据库、缓存和消息队列等中间件、负载均衡、带宽、依赖的第三方等,当并发达到某一个量级后,上述每个因素都可能成为扩展的瓶颈点。

03 高并发的实践方案有哪些?

了解了高并发设计的3大目标后,再系统性总结下高并发的设计方案,会从以下两部分展开:先总结下通用的设计方法,然后再围绕高性能、高可用、高扩展分别给出具体的实践方案。

3.1 通用的设计方法

通用的设计方法主要是从「纵向」和「横向」两个维度出发,俗称高并发处理的两板斧:纵向扩展和横向扩展。

3.1.1 纵向扩展(scale-up)

它的目标是提升单机的处理能力,方案又包括:

1、提升单机的硬件性能:通过增加内存、 CPU核数、存储容量、或者将磁盘 升级成SSD 等堆硬 件 的 方 式 来 提升 。

2、提升单机的软件性能:使用缓存减少IO次数,使用并发或者异步的方式增加吞吐量。

3.1.2 横向扩展(scale-out)

因为单机性能总会存在极限,所以最终还需要引入横向扩展,通过集群部署以进一步提高并发处理能力,又包括以下2个方向:

1、做好分层架构:这是横向扩展的提前,因为高并发系统往往业务复杂,通过分层处理可以简化复杂问题,更容易做到横向扩展。


上面这种图是互联网最常见的分层架构,当然真实的高并发系统架构会在此基础上进一步完善。比如会做动静分离并引入CDN,反向代理层可以是LVS+Nginx,Web层可以是统一的API网关,业务服务层可进一步按垂直业务做微服务化,存储层可以是各种异构数据库。

2、各层进行水平扩展:无状态水平扩容,有状态做分片路由。业务集群通常能设计成无状态的,而数据库和缓存往往是有状态的,因此需要设计分区键做好存储分片,当然也可以通过主从同步、读写分离的方案提升读性能。

3.2 具体的实践方案

下面再结合我的个人经验,针对高性能、高可用、高扩展3个方面,总结下可落地的实践方案。

3.2.1 高性能的实践方案

1、集群部署,通过负载均衡减轻单机压力。

2、多级缓存,包括静态数据使用CDN、本地缓存、分布式缓存等,以及对缓存场景中的热点key、缓存穿透、缓存并发、数据一致性等问题的处理。

3、分库分表和索引优化,以及借助搜索引擎解决复杂查询问题。

4、考虑NoSQL数据库的使用,比如HBase、TiDB等,但是团队必须熟悉这些组件,且有较强的运维能力。

5、异步化,将次要流程通过多线程、MQ、甚至延时任务进行异步处理。

6、限流,需要先考虑业务是否允许限流(比如秒杀场景是允许的),包括前端限流、Nginx接入层的限流、服务端的限流。

7、对流量进行 削峰填谷 ,通过 MQ承接流量。

8、并发处理,通过多线程将串行逻辑并行化。

9、预计算,比如抢红包场景,可以提前计算好红包金额缓存起来,发红包时直接使用即可。

10、 缓存预热 ,通过异步 任务 提前 预热数据到本地缓存或者分布式缓存中。

11、减少IO次数,比如数据库和缓存的批量读写、RPC的批量接口支持、或者通过冗余数据的方式干掉RPC调用。

12、减少IO时的数据包大小,包括采用轻量级的通信协议、合适的数据结构、去掉接口中的多余字段、减少缓存key的大小、压缩缓存value等。

13、程序逻辑优化,比如将大概率阻断执行流程的判断逻辑前置、For循环的计算逻辑优化,或者采用更高效的算法。

14、各种池化技术的使用和池大小的设置,包括HTTP请求池、线程池(考虑CPU密集型还是IO密集型设置核心参数)、数据库和Redis连接池等。

15、JVM优化,包括新生代和老年代的大小、GC算法的选择等,尽可能减少GC频率和耗时。

16、锁选择,读多写少的场景用乐观锁,或者考虑通过分段锁的方式减少锁冲突。

上述方案无外乎从计算和 IO 两个维度考虑所有可能的优化点,需要有配套的监控系统实时了解当前的性能表现,并支撑你进行性能瓶颈分析,然后再遵循二八原则,抓主要矛盾进行优化。

3.2.2 高可用的实践方案

1、对等节点的故障转移,Nginx和服务治理框架均支持一个节点失败后访问另一个节点。

2、非对等节点的故障转移,通过心跳检测并实施主备切换(比如redis的哨兵模式或者集群模式、MySQL的主从切换等)。

3、接口层面的超时设置、重试策略和幂等设计。

4、降级处理:保证核心服务,牺牲非核心服务,必要时进行熔断;或者核心链路出问题时,有备选链路。

5、限流处理:对超过系统处理能力的请求直接拒绝或者返回错误码。

6、MQ场景的消息可靠性保证,包括producer端的重试机制、broker侧的持久化、consumer端的ack机制等。

7、灰度发布,能支持按机器维度进行小流量部署,观察系统日志和业务指标,等运行平稳后再推全量。

8、监控报警:全方位的监控体系,包括最基础的CPU、内存、磁盘、网络的监控,以及Web服务器、JVM、数据库、各类中间件的监控和业务指标的监控。

9、灾备演练:类似当前的“混沌工程”,对系统进行一些破坏性手段,观察局部故障是否会引起可用性问题。

高可用的方案主要从冗余、取舍、系统运维3个方向考虑,同时需要有配套的值班机制和故障处理流程,当出现线上问题时,可及时跟进处理。

3.2.3 高扩展的实践方案

1、合理的分层架构:比如上面谈到的互联网最常见的分层架构,另外还能进一步按照数据访问层、业务逻辑层对微服务做更细粒度的分层(但是需要评估性能,会存在网络多一跳的情况)。

2、存储层的拆分:按照业务维度做垂直拆分、按照数据特征维度进一步做水平拆分(分库分表)。

3、业务层的拆分:最常见的是按照业务维度拆(比如电商场景的商品服务、订单服务等),也可以按照核心接口和非核心接口拆,还可以按照请求源拆(比如To C和To B,APP和H5 )。

写在最后

高并发确实是一个复杂且系统性的问题,由于篇幅有限,诸如分布式Trace、全链路压测、柔性事务都是要考虑的技术点。另外,如果业务场景不同,高并发的落地方案也会存在差异,但是总体的设计思路和可借鉴的方案基本类似。

高并发设计同样要秉承架构设计的3个原则:简单、合适和演进。”过早的优化是万恶之源”,不能脱离业务的实际情况,更不要过度设计,合适的方案就是最完美的。

希望这篇文章能带给你关于高并发更全面的认识,如果你也有可借鉴的经验和深入的思考,欢迎评论区留言讨论。

作者简介:985硕士,前亚马逊工程师,现58转转技术总监

欢迎关注我的个人公众号:IT人的职场进阶

本文转载自: 掘金

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

线程池最佳线程数量到底要如何配置?

发表于 2020-11-07

一、前言

对于从事后端开发的同学来说,线程是必须要使用了,因为使用它可以提升系统的性能。但是,创建线程和销毁线程都是比较耗时的操作,频繁的创建和销毁线程会浪费很多CPU的资源。此外,如果每个任务都创建一个线程去处理,这样线程会越来越多。我们知道每个线程默认情况下占1M的内存空间,如果线程非常多,内存资源将会被耗尽。这时,我们需要线程池去管理线程,不会出现内存资源被耗尽的情况,也不会出现频繁创建和销毁线程的情况,因为它内部是可以复用线程的。

二、从实战开始

在介绍线程池之前,让我们先看个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
csharp复制代码public class MyCallable implements Callable<String> {

@Override
public String call() throws Exception {
System.out.println("MyCallable call");
return "success";
}

public static void main(String[] args) {

ExecutorService threadPool = Executors.newSingleThreadExecutor();
try {

Future<String> future = threadPool.submit(new MyCallable());
System.out.println(future.get());
} catch (Exception e) {
System.out.println(e);
} finally {
threadPool.shutdown();
}

}

}

这个类的功能就是使用Executors类的newSingleThreadExecutor方法创建了的一个单线程池,他里面会执行Callable线程任务。

三、创建线程池的方法

我们仔细看看Executors类,会发现它里面给我们封装了不少创建线程池的静态方法,如下图所示:

)线程池最佳线程数量到底要如何配置?

其实,我们总结一下其实只有6种:

1.newCachedThreadPool可缓冲线程池

线程池最佳线程数量到底要如何配置?)

它的核心线程数是0,最大线程数是integer的最大值,每隔60秒回收一次空闲线程,使用SynchronousQueue队列。SynchronousQueue队列比较特殊,内部只包含一个元素,插入元素到队列的线程被阻塞,直到另一个线程从队列中获取了队列中存储的元素。同样,如果线程尝试获取元素并且当前不存在任何元素,则该线程将被阻塞,直到线程将元素插入队列。

2.newFixedThreadPool固定大小线程池

线程池最佳线程数量到底要如何配置?

它的核心线程数 和 最大线程数是一样,都是nThreads变量的值,该变量由用户自己决定,所以说是固定大小线程池。此外,它的每隔0毫秒回收一次线程,换句话说就是不回收线程,因为它的核心线程数 和 最大线程数是一样,回收了没有任何意义。此外,使用了LinkedBlockingQueue队列,该队列其实是有界队列,很多人误解了,只是它的初始大小比较大是integer的最大值。

3.newScheduledThreadPool定时任务线程池

线程池最佳线程数量到底要如何配置?

它的核心线程数是corePoolSize变量,需要用户自己决定,最大线程数是integer的最大值,同样,它的每隔0毫秒回收一次线程,换句话说就是不回收线程。使用了DelayedWorkQueue队列,该队列具有延时的功能。

4.newSingleThreadExecutor单个线程池

线程池最佳线程数量到底要如何配置?)

其实,跟上面的newFixedThreadPool是一样的,稍微有一点区别是核心线程数 和 最大线程数 都是1,这就是为什么说它是单线程池的原因。

5.newSingleThreadScheduledExecutor单线程定时任务线程池

线程池最佳线程数量到底要如何配置?)

该线程池是对上面介绍过的ScheduledThreadPoolExecutor定时任务线程池的简单封装,核心线程数固定是1,其他的功能一模一样。

6.newWorkStealingPool窃取线程池

线程池最佳线程数量到底要如何配置?

它是JDK1.8增加的新线程池,跟其他的实现方式都不一样,它底层是通过ForkJoinPool类来实现的。会创建一个含有足够多线程的线程池,来维持相应的并行级别,它会通过工作窃取的方式,使得多核的 CPU 不会闲置,总会有活着的线程让 CPU 去运行。

讲了这么多,具体要怎么用呢?

其实newFixedThreadPool、newCachedThreadPool、newSingleThreadExecutor 和 newWorkStealingPool方法创建和使用线程池的方法是一样的。这四个方法创建线程池返回值是ExecutorService,通过它的execute方法执行线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
typescript复制代码public class MyWorker implements Runnable {

@Override
public void run() {
System.out.println("MyWorker run");
}

public static void main(String[] args) {
ExecutorService threadPool = Executors.newFixedThreadPool(8);
try {
threadPool.execute(new MyWorker());
} catch (Exception e) {
System.out.println(e);
} finally {
threadPool.shutdown();
}

}
}

newScheduledThreadPool 和 newSingleThreadScheduledExecutor 方法创建和使用线程池的方法也是一样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typescript复制代码public class MyTask implements Runnable {

@Override
public void run() {
System.out.println("MyTask call");
}

public static void main(String[] args) {

ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(8);
try {
scheduledExecutorService.schedule(new MyRunnable(), 60, TimeUnit.SECONDS);
} finally {
scheduledExecutorService.shutdown();
}
}
}

以上两个方法创建的线程池返回值是ScheduledExecutorService,通过它的schedule提交线程,并且可以配置延迟执行的时间。

四、自定义线程池

Executors类有这么多方法可以创建线程池,但是阿里巴巴开发规范中却明确规定不要使用Executors类创建线程池,这是为什么呢?

newCachedThreadPool可缓冲线程池,它的最大线程数是integer的最大值,意味着使用它创建的线程池,可以创建非常多的线程,我们都知道一个线程默认情况下占用内存1M,如果创建的线程太多,占用内存太大,最后肯定会出现内存溢出的问题。

newFixedThreadPool和newSingleThreadExecutor在这里都称为固定大小线程池,它的队列使用的LinkedBlockingQueue,我们都知道这个队列默认大小是integer的最大值,意味着可以往该队列中加非常多的任务,每个任务也是要内存空间的,如果任务太多,最后肯定也会出现内存溢出的问题。

阿里建议使用ThreadPoolExecutor类创建线程池,其实从刚刚看到的Executors类创建线程池的newFixedThreadPool等方法可以看出,它也是使用ThreadPoolExecutor类创建线程池的。

线程池最佳线程数量到底要如何配置?

线程池最佳线程数量到底要如何配置?

从上图可以看出ThreadPoolExecutor类的构造方法有4个,里面包含了很多参数,让我们先一起认识一下:

1
2
3
4
5
6
ini复制代码corePoolSize:核心线程数
maximumPoolSize:最大线程数
keepAliveTime:空闲线程回收时间间隔
unit:空闲线程回收时间间隔单位
workQueue:提交任务的队列,当线程数量超过核心线程数时,可以将任务提交到任务队列中。比较常用的有:ArrayBlockingQueue; LinkedBlockingQueue; SynchronousQueue;
threadFactory:线程工厂,可以自定义线程的一些属性,比如:名称或者守护线程等handler:表示当拒绝处理任务时的策略从上面可以看到,我们使用ThreadPoolExecutor类自定义了一个线程池,它的核心线程数是8,最大线程数是 10,空闲线程回收时间是30,单位是秒,存放任务的队列用的ArrayBlockingQueue,而队列满的处理策略用的AbortPolicy。使用这个队列,基本可以保持线程在系统的可控范围之内,不会出现内存溢出的问题。但是也不是绝对的,只是出现内存溢出的概率比较小。

handler:表示当拒绝处理任务时的策略

ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。

ThreadPoolExecutor.DiscardPolicy:也是丢弃任务,但是不抛出异常。

ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)

ThreadPoolExecutor.CallerRunsPolicy:由调用线程处理该任务

我们根据上面的内容自定义一个线程池:

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
java复制代码public class MyThreadPool implements  Runnable {

private static final ExecutorService executorService = new ThreadPoolExecutor(
8,
10,
30,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500),
new ThreadPoolExecutor.AbortPolicy());

@Override
public void run() {
System.out.println("MyThreadPool run");
}

public static void main(String[] args) {
int availableProcessors = Runtime.getRuntime().availableProcessors();
try {
executorService.execute(new MyThreadPool());
} catch (Exception e) {
System.out.println(e);
} finally {
executorService.shutdown();
}
}
}

从上面可以看到,我们使用ThreadPoolExecutor类自定义了一个线程池,它的核心线程数是8,最大线程数是 10,空闲线程回收时间是30,单位是秒,存放任务的队列用的ArrayBlockingQueue,而队列满的处理策略用的AbortPolicy。使用这个队列,基本可以保持线程在系统的可控范围之内,不会出现内存溢出的问题。但是也不是绝对的,只是出现内存溢出的概率比较小。

当然,阿里巴巴开发规范建议不使用Executors类创建线程池,并不表示它完全没用,在一些低并发的业务场景照样可以使用。

五、最佳线程数

在使用线程池时,很多同学都有这样的疑问,不知道如何配置线程数量,今天我们一起探讨一下这个问题。

1.经验值

配置线程数量之前,首先要看任务的类型是 IO密集型,还是CPU密集型?

什么是IO密集型?

比如:频繁读取磁盘上的数据,或者需要通过网络远程调用接口。

什么是CPU密集型?

比如:非常复杂的调用,循环次数很多,或者递归调用层次很深等。

IO密集型配置线程数经验值是:2N,其中N代表CPU核数。

CPU密集型配置线程数经验值是:N + 1,其中N代表CPU核数。

如果获取N的值?

1
ini复制代码int availableProcessors = Runtime.getRuntime().availableProcessors();

那么问题来了,混合型(既包含IO密集型,又包含CPU密集型)的如何配置线程数?

混合型如果IO密集型,和CPU密集型的执行时间相差不太大,可以拆分开,以便于更好配置。如果执行时间相差太大,优化的意义不大,比如IO密集型耗时60s,CPU密集型耗时1s。

2.最佳线程数目算法

除了上面介绍是经验值之外,其实还提供了计算公式:

1
复制代码最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目

很显然线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。

虽说最佳线程数目算法更准确,但是线程等待时间和线程CPU时间不好测量,实际情况使用得比较少,一般用经验值就差不多了。再配合系统压测,基本可以确定最适合的线程数。

本文转载自: 掘金

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

SpringMVC执行流程(与代码映照)

发表于 2020-11-07

概述

SpringMVC 是一种轻量级的,基于 MVC 的 web 层应用框架。偏前端而不是基于业务逻辑层。是 Spring 框架的一个后续产品。

特点

有清晰的角色划分:

  1. 中央调度器(DispatcherServlet):作为前端控制器,整个流程的控制中心,控制其它组件执行,同一调度。
  2. 处理器映射器(HandlerMapping):负责根据用户请求的 url 找到 Handler 处理器(Handler 是执行一个特定功能的函数)。
  3. 处理器适配器(HandlerAdapter):执行处理器。
  4. 视图解析器(ViewResolver):解析 ModelAndView 。

之所以有角色的划分是为了让程序能更好的解耦,提高程序的扩展性。

执行流程

在这里插入图片描述

在这里插入图片描述

  1. 中央调度器(DispatcherServlet)接收请求并且调用处理器映射器(HandlerMapping)。
  2. 处理器映射器(HandlerMapping)负责根据用户请求的 url 找到与之绑定的函数(即 Handler 处理器),并返回给中央调度器。
  3. 中央调度器(DispatcherServlet)调用处理器适配器(HandlerAdapter)处理一系列操作,如:参数封装,数据格式转换,数据验证等,再执行处理器。
  4. 处理器(Handler)执行完成后返回 ModelAndView。
  5. 处理器适配器(HandlerAdapater)将处理器返回的结果 ModelAndView 也返回给中央调度器。
  6. 中央调度器(DispatcherServlet)将 ModelAndView 返回给 ViewReslover(视图解析器)进行解析。解析后返回具体的 view。
  7. 中央调度器(DispatcherServlet)对 view 进行渲染视图并响应用户。

代码对应

整个代码

  1. web.xml
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
复制代码<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<servlet>
<servlet-name>myweb</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>

<!-- 自定义 springmvc 读取的配置文件的位置 -->
<init-param>
<!-- springmvc 的配置文件的位置的属性-->
<param-name>contextConfigLocation</param-name>
<!-- 指定自定义文件的位置-->
<param-value>classpath:springmvc.xml</param-value>
</init-param>

<!-- 在 tomcat 启动后,创建 servlet 对象
load-on-startup:表示 tomcat 启动后创建对象的顺序。他的值时整数,
数值越小,tomcat 创建对象的时间越早,大于等于 0 的整数。
-->
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>myweb</servlet-name>
<url-pattern>*.do</url-pattern>
</servlet-mapping>
</web-app>
  1. springmvc.xml
1
2
3
4
5
6
7
8
9
10
11
12
复制代码<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">
<!-- 视图解析器 -->
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name="prefix" value="/WEB-INF/jsp" />
<property name="suffix" value=".jsp" />
</bean>
<!-- 声明组件扫描器 -->
<context:component-scan base-package="com.manman.controller"/>
</beans>
  1. Controller
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码package com.manman.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

/**
* @Controller:创建处理器对象,对象放在 springmvc 容器中。
* 能处理请求的都是控制器(处理器):MyController 能处理请求,叫做后端控制器
*/
@Controller("myController")
public class MyController {

@RequestMapping(value = "/some.do")
public ModelAndView doSome(){
// 处理 some.do 请求
ModelAndView mv = new ModelAndView();
mv.addObject("msg", "hello world");
mv.addObject("fun", "执行的是 doSome 方法");
mv.setViewName("/show.jsp");
// 返回 mv
return mv;
}
}

代码解析

  1. 中央调度器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码<servlet>
<servlet-name>myweb</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<!-- 自定义 springmvc 读取的配置文件的位置 -->
<init-param>
<!-- springmvc 的配置文件的位置的属性-->
<param-name>contextConfigLocation</param-name>
<!-- 指定自定义文件的位置-->
<param-value>classpath:springmvc.xml</param-value>
</init-param>
<!-- 在 tomcat 启动后,创建 servlet 对象
load-on-startup:表示 tomcat 启动后创建对象的顺序。他的值时整数,
数值越小,tomcat 创建对象的时间越早,大于等于 0 的整数。
-->
<load-on-startup>1</load-on-startup>
</servlet>

<servlet-mapping>
<servlet-name>myweb</servlet-name>
<url-pattern>*.do</url-pattern>
</servlet-mapping>
  1. 处理器映射器
1
2
复制代码@RequestMapping(value = "/some.do")
public ModelAndView doSome(){}
  1. 调用处理器适配器并执行处理器
1
2
3
4
5
6
7
8
9
复制代码public ModelAndView doSome(){
// 处理 some.do 请求
ModelAndView mv = new ModelAndView();
mv.addObject("msg", "hello world");
mv.addObject("fun", "执行的是 doSome 方法");
mv.setViewName("/show.jsp");
// 返回 mv
return mv;
}
  1. 视图解析器进行解析
1
2
3
4
5
复制代码<!-- 视图解析器 -->
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name="prefix" value="/WEB-INF/jsp" />
<property name="suffix" value=".jsp" />
</bean>

本文使用 mdnice 排版

本文转载自: 掘金

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

Java函数式编程最佳实践

发表于 2020-11-07

别人说烂了的stream api不就不想赘述了,我想和大家分享一下,如何用函数式编程来简化我们的开发,想说点不一样的东西

简化事务

对于事务而言,应该粒度越小越好,并且读写逻辑应该分开,只在写的逻辑上执行事务,可以用函数式编程来简化抽去写逻辑这一步

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码@Service
public class TransactionService {
@Transactional
public void process(ThrowExceptionRunnable runnable){
try {
runnable.run();
}catch (Exception e){
new RuntimeException(e);
}
}
}

//使用方式
public void regist(String username){
User user = userService.findByUserName(username);
if(user != null) return;

//执行事务 注册用户 开通余额账号
transactionService.process(() -> {
userService.save(new User(username));
balanceService.save(new Balance(username));
});
}

赋予方法重试能力

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
java复制代码    public static void retryFunction(ThrowExceptionRunnable runnable, int time) {
while (true) {
try {
runnable.run();
return;
} catch (Exception e) {
time--;
if (time <= 0) throw new RuntimeException(e);
}
}
}
public static <T, R> R retryFunction(ThrowExceptionFunction<T, R> function, T t, int time) {
while (true) {
try {
return function.apply(t);
} catch (Exception e) {
time--;
if (time <= 0) throw new RuntimeException(e);
}
}
}
public static <T, U, R> R retryFunction(ThrowExceptionBiFunction<T, U, R> function, T t, U u, int time) {
while (true) {
try {
return function.apply(t, u);
} catch (Exception e) {
time--;
if (time <= 0) throw new RuntimeException(e);
}
}
}
public static void main(String[] args) {
//http调用,失败会重试3次
retryFunction(()->http.call(),3);
//把数字1转成数字 失败会重试三次
String s = retryFunction(String::valueOf, 1, 3);
String ss = retryFunction(i -> String.valueOf(i), 1, 3);
}

赋予函数缓存能力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码public static <T, R> R cacheFunction(Function<T, R> function, T t, Map<T, R> cache) {
R r = cache.get(t);
if (r != null) return r;
R result = function.apply(t);
cache.put(t,result);
return result;
}

public static void main(String[] args) {
Map<String,User> cache = new HashMap<Integer, User>();
String username = "张三";
//不走缓存
cacheFunction(u -> userService.findByUserName(u),username,cache);
//走缓存
cacheFunction(u -> userService.findByUserName(u),username,cache);
}

赋予函数报错返回默认值能力

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
java复制代码    public static <T, R> R computeOrGetDefault(ThrowExceptionFunction<T, R> function, T t, R r) {
try {
return function.apply(t);
} catch (Exception e) {
return r;
}
}
public static <R> R computeOrGetDefault(ThrowExceptionSupplier<R> supplier,R r){
try {
return supplier.get();
} catch (Exception e) {
return r;
}
}

public static void main(String[] args) {
//返回0
computeOrGetDefault(i -> {
if (i < 0) throw new RuntimeException();
else return i;
}, -1, 0);
//返回5
computeOrGetDefault(i -> {
if (i < 0) throw new RuntimeException();
else return i;
},5,0);
}

赋予函数处理异常的能力

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
java复制代码    public static <T, R> R computeAndDealException(ThrowExceptionFunction<T, R> function, T t, Function<Exception, R> dealFunc) {
try {
return function.apply(t);
} catch (Exception e) {
return dealFunc.apply(e);
}
}

public static <T, U, R> R computeAndDealException(ThrowExceptionBiFunction<T,U, R> function, T t, U u,Function<Exception, R> dealFunc) {
try {
return function.apply(t,u);
} catch (Exception e) {
return dealFunc.apply(e);
}
}

public static <R> R computeAndDealException(ThrowExceptionSupplier<R> supplier, Function<Exception, R> dealFunc) {
try {
return supplier.get();
} catch (Exception e) {
return dealFunc.apply(e);
}
}

public static void main(String[] args) {
//返回异常message的hashcode
Integer integer = computeAndDealException(i -> {
if (i < 0) throw new RuntimeException("不能小于0");
else return i;
}, -1, e -> e.getMessage().hashCode());
System.out.println(integer);

}

赋予函数记录日志能力

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public static <T, R> R logFunction(Function<T, R> function, T t, String logTitle) {
long startTime = System.currentTimeMillis();
log.info("[[title={}]],request={},requestTime={}", logTitle, t.toString(),
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
R apply = function.apply(t);
long endTime = System.currentTimeMillis();
log.info("[[title={}]],response={},spendTime={}ms", logTitle, apply.toString(), endTime - startTime);
return apply;
}

public static void main(String[] args) {
logFunction(String::valueOf,"s","String.valueOf");
}

自定义函数接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码    @FunctionalInterface
public interface ThrowExceptionFunction<T, R> {
R apply(T t) throws Exception;
}

@FunctionalInterface
public interface ThrowExceptionBiFunction<T, U, R> {
R apply(T t, U u) throws Exception;
}
@FunctionalInterface
public interface ThrowExceptionSupplier<T> {
T get() throws Exception;
}
@FunctionalInterface
public interface ThrowExceptionRunnable {
void run() throws Exception;
}

Q:为什么要自定义函数接口

A:自带的函数接口无法处理检查异常,遇见带检查异常的方法会报错

我哪些场景用到了?

链式取数

在翻译php代码的时候我们常常遇到如下情况

1
php复制代码$s = a.b.c.d.e.f.g

然后翻译成java代码的时候是这样的

1
java复制代码String s = a.getB().getC().getD().getE().getF().getG();

有啥问题?没有没有判空,只要中间有一层为空,那么就是NPE,要是去写判空逻辑的话,真是要了命了

这时我们就可以用上上面提到的骚操作了

代码改写

1
java复制代码String s = computeOrGetDefault(()->a.getB().getC().getD().getE().getF().getG(),"");
事务
简单的降级操作(computeAndDealException)
接口重试
接口缓存
记录日志

本文转载自: 掘金

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

Spring Boot 第十七弹,Swagger 30 天

发表于 2020-11-06

前言

最近频繁被Swagger 3.0刷屏,官方表示这是一个突破性的变更,有很多的亮点,我还真不太相信,今天来带大家尝尝鲜,看看这碗汤到底鲜不鲜….

官方文档如何说?

该项目开源在Github上,地址:github.com/springfox/s…。

Swagger 3.0有何改动?官方文档总结如下几点:

  1. 删除了对springfox-swagger2的依赖
  2. 删除所有@EnableSwagger2...注解
  3. 添加了springfox-boot-starter依赖项
  4. 移除了guava等第三方依赖
  5. 文档访问地址改变了,改成了http://ip:port/project/swagger-ui/index.html。

姑且看到这里,各位初始感觉如何?

既然人家更新出来了,咱不能不捧场,下面就介绍下Spring Boot如何整合Swagger 3.0吧。

Spring Boot版本说明

作者使用Spring Boot的版本是2.3.5.RELEASE

添加依赖

Swagger 3.0已经有了与Spring Boot整合的启动器,只需要添加以下依赖:

1
2
3
4
5
xml复制代码  <dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-boot-starter</artifactId>
<version>3.0.0</version>
</dependency>

springfox-boot-starter做了什么?

Swagger 3.0主推的一大特色就是这个启动器,那么这个启动器做了什么呢?

记住:启动器的一切逻辑都在自动配置类中。

找到springfox-boot-starter的自动配置类,在/META-INF/spring.factories文件中,如下:

从上图可以知道,自动配置类就是OpenApiAutoConfiguration,源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码@Configuration
@EnableConfigurationProperties(SpringfoxConfigurationProperties.class)
@ConditionalOnProperty(value = "springfox.documentation.enabled", havingValue = "true", matchIfMissing = true)
@Import({
OpenApiDocumentationConfiguration.class,
SpringDataRestConfiguration.class,
BeanValidatorPluginsConfiguration.class,
Swagger2DocumentationConfiguration.class,
SwaggerUiWebFluxConfiguration.class,
SwaggerUiWebMvcConfiguration.class
})
@AutoConfigureAfter({ WebMvcAutoConfiguration.class, JacksonAutoConfiguration.class,
HttpMessageConvertersAutoConfiguration.class, RepositoryRestMvcAutoConfiguration.class })
public class OpenApiAutoConfiguration {

}

敢情这个自动配置类啥也没干,就光导入了几个配置类(@Import)以及开启了属性配置(@EnableConfigurationProperties)。

3

重点:记住OpenApiDocumentationConfiguration这个配置类,初步看来这是个BUG,本人也不想深入,里面的代码写的实在拙劣,注释都不写。

撸起袖子就是干?

说真的,还是和以前一样,真的没什么太大的改变,按照文档的步骤一步步来。

定制一个基本的文档示例

一切的东西还是需要配置类手动配置,说真的,我以为会在全局配置文件中自己配置就行了。哎,想多了。配置类如下:

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
java复制代码@EnableOpenApi
@Configuration
@EnableConfigurationProperties(value = {SwaggerProperties.class})
public class SwaggerConfig {
/**
* 配置属性
*/
@Autowired
private SwaggerProperties properties;

@Bean
public Docket frontApi() {
return new Docket(DocumentationType.OAS_30)
//是否开启,根据环境配置
.enable(properties.getFront().getEnable())
.groupName(properties.getFront().getGroupName())
.apiInfo(frontApiInfo())
.select()
//指定扫描的包
.apis(RequestHandlerSelectors.basePackage(properties.getFront().getBasePackage()))
.paths(PathSelectors.any())
.build();
}

/**
* 前台API信息
*/
private ApiInfo frontApiInfo() {
return new ApiInfoBuilder()
.title(properties.getFront().getTitle())
.description(properties.getFront().getDescription())
.version(properties.getFront().getVersion())
.contact( //添加开发者的一些信息
new Contact(properties.getFront().getContactName(), properties.getFront().getContactUrl(),
properties.getFront().getContactEmail()))
.build();
}
}

@EnableOpenApi这个注解文档解释如下:

1
2
3
doc复制代码Indicates that Swagger support should be enabled.
This should be applied to a Spring java config and should have an accompanying '@Configuration' annotation.
Loads all required beans defined in @see SpringSwaggerConfig

什么意思呢?大致意思就是只有在配置类标注了@EnableOpenApi这个注解才会生成Swagger文档。

@EnableConfigurationProperties这个注解使开启自定义的属性配置,这是作者自定义的Swagger配置。

总之还是和之前一样配置,根据官方文档要求,需要在配置类上加一个@EnableOpenApi注解。

文档如何分组?

我们都知道,一个项目可能分为前台,后台,APP端,小程序端…..每个端的接口可能还相同,不可能全部放在一起吧,肯定是要区分开的。

因此,实际开发中文档肯定是要分组的。

分组其实很简单,Swagger向IOC中注入一个Docket即为一个组的文档,其中有个groupName()方法指定分组的名称。

因此只需要注入多个Docket指定不同的组名即可,当然,这些文档的标题、描述、扫描的路径都是可以不同定制的。

如下配置两个Docket,分为前台和后台,配置类如下:

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
java复制代码@EnableOpenApi
@Configuration
@EnableConfigurationProperties(value = {SwaggerProperties.class})
public class SwaggerConfig {
/**
* 配置属性
*/
@Autowired
private SwaggerProperties properties;

@Bean
public Docket frontApi() {
return new Docket(DocumentationType.OAS_30)
//是否开启,根据环境配置
.enable(properties.getFront().getEnable())
.groupName(properties.getFront().getGroupName())
.apiInfo(frontApiInfo())
.select()
//指定扫描的包
.apis(RequestHandlerSelectors.basePackage(properties.getFront().getBasePackage()))
.paths(PathSelectors.any())
.build();
}

/**
* 前台API信息
*/
private ApiInfo frontApiInfo() {
return new ApiInfoBuilder()
.title(properties.getFront().getTitle())
.description(properties.getFront().getDescription())
.version(properties.getFront().getVersion())
.contact( //添加开发者的一些信息
new Contact(properties.getFront().getContactName(), properties.getFront().getContactUrl(),
properties.getFront().getContactEmail()))
.build();
}

/**
* 后台API
*/
@Bean
public Docket backApi() {
return new Docket(DocumentationType.OAS_30)
//是否开启,根据环境配置
.enable(properties.getBack().getEnable())
.groupName("后台管理")
.apiInfo(backApiInfo())
.select()
.apis(RequestHandlerSelectors.basePackage(properties.getBack().getBasePackage()))
.paths(PathSelectors.any())
.build();
}

/**
* 后台API信息
*/
private ApiInfo backApiInfo() {
return new ApiInfoBuilder()
.title(properties.getBack().getTitle())
.description(properties.getBack().getDescription())
.version(properties.getBack().getVersion())
.contact( //添加开发者的一些信息
new Contact(properties.getBack().getContactName(), properties.getBack().getContactUrl(),
properties.getBack().getContactEmail()))
.build();
}

}

属性配置文件SwaggerProperties如下,分为前台和后台两个不同属性的配置:

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
java复制代码/**
* swagger的属性配置类
*/
@ConfigurationProperties(prefix = "spring.swagger")
@Data
public class SwaggerProperties {

/**
* 前台接口配置
*/
private SwaggerEntity front;

/**
* 后台接口配置
*/
private SwaggerEntity back;

@Data
public static class SwaggerEntity {
private String groupName;
private String basePackage;
private String title;
private String description;
private String contactName;
private String contactEmail;
private String contactUrl;
private String version;
private Boolean enable;
}
}

此时的文档截图如下,可以看到有了两个不同的分组:

如何添加授权信息?

现在项目API肯定都需要权限认证,否则不能访问,比如请求携带一个TOKEN。

在Swagger中也是可以配置认证信息,这样在每次请求将会默认携带上。

在Docket中有如下两个方法指定授权信息,分别是securitySchemes()和securityContexts()。在配置类中的配置如下,在构建Docket的时候设置进去即可:

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
java复制代码
@Bean
public Docket frontApi() {
RequestParameter parameter = new RequestParameterBuilder()
.name("platform")
.description("请求头")
.in(ParameterType.HEADER)
.required(true)
.build();
List<RequestParameter> parameters = Collections.singletonList(parameter);
return new Docket(DocumentationType.OAS_30)
//是否开启,根据环境配置
.enable(properties.getFront().getEnable())
.groupName(properties.getFront().getGroupName())
.apiInfo(frontApiInfo())
.select()
//指定扫描的包
.apis(RequestHandlerSelectors.basePackage(properties.getFront().getBasePackage()))
.paths(PathSelectors.any())
.build()
.securitySchemes(securitySchemes())
.securityContexts(securityContexts());
}

/**
* 设置授权信息
*/
private List<SecurityScheme> securitySchemes() {
ApiKey apiKey = new ApiKey("BASE_TOKEN", "token", In.HEADER.toValue());
return Collections.singletonList(apiKey);
}

/**
* 授权信息全局应用
*/
private List<SecurityContext> securityContexts() {
return Collections.singletonList(
SecurityContext.builder()
.securityReferences(Collections.singletonList(new SecurityReference("BASE_TOKEN", new AuthorizationScope[]{new AuthorizationScope("global", "")})))
.build()
);
}

以上配置成功后,在Swagger文档的页面中将会有Authorize按钮,只需要将请求头添加进去即可。如下图:

如何携带公共的请求参数?

不同的架构可能发请求的时候除了携带TOKEN,还会携带不同的参数,比如请求的平台,版本等等,这些每个请求都要携带的参数称之为公共参数。

那么如何在Swagger中定义公共的参数呢?比如在请求头中携带。

在Docket中的方法globalRequestParameters()可以设置公共的请求参数,接收的参数是一个List<RequestParameter>,因此只需要构建一个RequestParameter集合即可,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码@Bean
public Docket frontApi() {
//构建一个公共请求参数platform,放在在header
RequestParameter parameter = new RequestParameterBuilder()
//参数名称
.name("platform")
//描述
.description("请求的平台")
//放在header中
.in(ParameterType.HEADER)
//是否必传
.required(true)
.build();
//构建一个请求参数集合
List<RequestParameter> parameters = Collections.singletonList(parameter);
return new Docket(DocumentationType.OAS_30)
.....
.build()
.globalRequestParameters(parameters);
}

以上配置完成,将会在每个接口中看到一个请求头,如下图:

粗略是一个BUG

作者在介绍自动配置类的时候提到了一嘴,现在来简单分析下。

OpenApiAutoConfiguration这个自动配置类中已经导入OpenApiDocumentationConfiguration这个配置类,如下一段代码:

1
2
3
4
java复制代码@Import({
OpenApiDocumentationConfiguration.class,
......
})

@EnableOpenApi的源码如下:

1
2
3
4
5
6
java复制代码@Retention(value = java.lang.annotation.RetentionPolicy.RUNTIME)
@Target(value = {java.lang.annotation.ElementType.TYPE})
@Documented
@Import(OpenApiDocumentationConfiguration.class)
public @interface EnableOpenApi {
}

从源码可以看出:@EnableOpenApi这个注解的作用就是导入OpenApiDocumentationConfiguration这个配置类,纳尼???

既然已经在自动配置类OpenApiAutoConfiguration导入了,那么无论需不需要在配置类上标注@EnableOpenApi注解不都会开启Swagger支持吗?

测试一下:不在配置类上标注@EnableOpenApi这个注解,看看是否Swagger运行正常。结果在意料之中,还是能够正常运行。

总结:作者只是大致分析了下,这可能是个BUG亦或是后续有其他的目的,至于结果如此,不想验证了,没什么意思。

总结

这篇文章也是尝了个鲜,个人感觉不太香,有点失望。你喜欢吗?

Spring Boot 整合的源码已经上传,需要的朋友回复关键词Swagger3.0获取。点击前往

本文转载自: 掘金

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

1…768769770…956

开发者博客

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