简栈文化

Java技术人的成长之路~

什么是CAS

CASCompare And Swap的缩写,翻译成中文就是比较并交换,其作用是让CPU比较内存中某个值是否和预期的值相同,如果相同则将这个值更新为新值,不相同则不做更新,也就是CAS是原子性的操作(读和写两者同时具有原子性),其实现方式是通过借助C/C++调用CPU指令完成的,所以效率很高。
CAS的原理很简单,这里使用一段Java代码来描述

1
2
3
4
5
6
7
8
9
public boolean compareAndSwap(int value, int expect, int update) {
// 如果内存中的值value和期望值expect一样 则将值更新为新值update
if (value == expect) {
value = update;
return true;
} else {
return false;
}
}

大致过程是将内存中的值、我们的期望值、新值交给CPU进行运算,如果内存中的值和我们的期望值相同则将值更新为新值,否则不做任何操作。这个过程是在CPU中完成的,这里不好描述CPU的工作过程,就拿Java代码来描述了。

Unsafe源码分析

Java是在Unsafe(sun.misc.Unsafe)类实现CAS的操作,而我们知道Java是无法直接访问操作系统底层的API的(原因是Java的跨平台性限制了Java不能和操作系统耦合),所以Java并没有在Unsafe类直接实现CAS的操作,而是通过**JDI(Java Native Interface)**本地调用C/C++语言来实现CAS操作的。
Unsafe有很多个CAS操作的相关方法,这里举例几个

1
2
3
4
5
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

我们拿public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);进行分析,这个方法是比较内存中的一个值(整型)和我们的期望值(var4)是否一样,如果一样则将内存中的这个值更新为var5,参数中的var1是值所在的对象,var2是值在对象(var1)中的内存偏移量,参数var1和参数var2是为了定位出值所在内存的地址
http://static.cyblogs.com/e0e01e43ly1g11dbvrjwoj21ay0bujtl.jpg
Unsafe.java在这里发挥的作用有:

  1. 将对象引用、值在对象中的偏移量、期望的值和欲更新的新值传递给Unsafe.cpp
  2. 如果值更新成功则返回true给开发者,没有更新则返回false

Unsafe.cpp在这里发挥的作用有:

  1. 接受从Unsafe传递过来的对象引用、偏移量、期望的值和欲更新的新值,根据对象引用和偏移量计算出值的地址,然后将值的地址、期望的值、欲更新的新值传递给CPU
  2. 如果值更新成功则返回trueUnsafe.java,没有更新则返回false

CPU在这里发挥的作用:

  1. 接受从Unsafe.cpp传递过来的地址、期望的值和欲更新的新值,执行指令cmpxchg,比较地址中的值是否和期望的值一样,一样则将值更新为新的值,不一样则不做任何操作
  2. 将操作结果返回给Unsafe.cpp

CAS的缺点

CAS虽然高效的实现了原子性操作,但是也存在一些缺点,主要表现在以下三个方面。

ABA问题

在多线程场景下CAS会出现ABA问题,关于ABA问题这里简单科普下,例如有2个线程同时对同一个值(初始值为A)进行CAS操作,这三个线程如下

  1. 线程1,期望值为A,欲更新的值为B
  2. 线程2,期望值为A,欲更新的值为B

线程1抢先获得CPU时间片,而线程2因为其他原因阻塞了,线程1取值与期望的A值比较,发现相等然后将值更新为B,然后这个时候出现了线程3,期望值为B,欲更新的值为A,线程3取值与期望的值B比较,发现相等则将值更新为A,此时线程2从阻塞中恢复,并且获得了CPU时间片,这时候线程2取值与期望的值A比较,发现相等则将值更新为B,虽然线程2也完成了操作,但是线程2并不知道值已经经过了A->B->A的变化过程。

ABA问题带来的危害
小明在提款机,提取了50元,因为提款机问题,有两个线程,同时把余额从100变为50
线程1(提款机):获取当前值100,期望更新为50,
线程2(提款机):获取当前值100,期望更新为50,
线程1成功执行,线程2某种原因block了,这时,某人给小明汇款50
线程3(默认):获取当前值50,期望更新为100,
这时候线程3成功执行,余额变为100,
线程2从Block中恢复,获取到的也是100,compare之后,继续更新余额为50!!!
此时可以看到,实际余额应该为100(100-50+50),但是实际上变为了50(100-50+50-50)这就是ABA问题带来的成功提交。

解决方法
在变量前面加上版本号,每次变量更新的时候变量的版本号都+1,即A->B->A就变成了1A->2B->3A

循环时间长开销大

如果CAS操作失败,就需要循环进行CAS操作(循环同时将期望值更新为最新的),如果长时间都不成功的话,那么会造成CPU极大的开销。

这种循环也称为自旋

解决方法
限制自旋次数,防止进入死循环。

只能保证一个共享变量的原子操作

CAS的原子操作只能针对一个共享变量。

解决方法
如果需要对多个共享变量进行操作,可以使用加锁方式(悲观锁)保证原子性,或者可以把多个共享变量合并成一个共享变量进行CAS操作。

CAS的应用

我们知道CAS操作并不会锁住共享变量,也就是一种非阻塞的同步机制,CAS就是乐观锁的实现。

  1. 乐观锁
    乐观锁总是假设最好的情况,每次去操作数据都认为不会被别的线程修改数据,所以在每次操作数据的时候都不会给数据加锁,即在线程对数据进行操作的时候,别的线程不会阻塞仍然可以对数据进行操作,只有在需要更新数据的时候才会去判断数据是否被别的线程修改过,如果数据被修改过则会拒绝操作并且返回错误信息给用户。
  2. 悲观锁
    悲观锁总是假设最坏的情况,每次去操作数据时候都认为会被的线程修改数据,所以在每次操作数据的时候都会给数据加锁,让别的线程无法操作这个数据,别的线程会一直阻塞直到获取到这个数据的锁。这样的话就会影响效率,比如当有个线程发生一个很耗时的操作的时候,别的线程只是想获取这个数据的值而已都要等待很久。

Java利用CAS的乐观锁、原子性的特性高效解决了多线程的安全性问题,例如JDK1.8中的集合类ConcurrentHashMap、关键字volatileReentrantLock等。

参考地址

ArrayList循环遍历并删除元素的常见陷阱

在工作和学习中,经常碰到删除ArrayList里面的某个元素,看似一个很简单的问题,却很容易出bug。不妨把这个问题当做一道面试题目,我想一定能难道不少的人。今天就给大家说一下在ArrayList循环遍历并删除元素的问题。首先请看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.util.ArrayList;
public class ArrayListRemove
{
  public static void main(String[]args)
  {
    ArrayList<String>list=newArrayList<String>();
    list.add("a");
    list.add("b");
    list.add("b");
    list.add("c");
    list.add("c");
    list.add("c");
    remove(list);
    for(Strings:list)
    {
      System.out.println("element : "+s);
    }
  }
  public static void remove(ArrayList<String> list)
  {
  // TODO:
  }
}

如果要想删除list的b字符,有下面两种常见的错误例子:

错误写法实例一:

1
2
3
4
5
6
7
8
9
10
11
public static void remove(ArrayList<String> list)
{
for(inti=0;i<list.size();i++)
{
Strings=list.get(i);
if(s.equals("b"))
{
list.remove(s);
}
}
}

错误的原因:这种最普通的循环写法执行后会发现第二个“b”的字符串没有删掉。

错误写法实例二:

1
2
3
4
5
6
7
8
9
10
public static void remove(ArrayList<String> list)
{
for(Strings:list)
{
if(s.equals("b"))
{
list.remove(s);
}
}
}

错误的原因:这种for-each写法会报出著名的并发修改异常:java.util.ConcurrentModificationException。

先解释一下实例一的错误原因。翻开JDK的ArrayList源码,先看下ArrayList中的remove方法(注意ArrayList中的remove有两个同名方法,只是入参不同,这里看的是入参为Object的remove方法)是怎么实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public boolean remove(Objecto){
if(o==null){
for(intindex=0;index<size;index++)
if(elementData[index]==null){
fastRemove(index);
return true;
}
}else{
for(intindex=0;index<size;index++)
if(o.equals(elementData[index])){
fastRemove(index);
return true;
}
}
return false;
}

一般情况下程序的执行路径会走到else路径下最终调用faseRemove方法:

1
2
3
4
5
6
7
private void fastRemove(int index){
modCount++;
intnumMoved=size-index-1;
if(numMoved>0)
  System.arraycopy(elementData,index+1,elementData,index,numMoved);
elementData[--size]=null;// Let gc do its work
}

可以看到会执行System.arraycopy方法,导致删除元素时涉及到数组元素的移动。针对错误写法一,在遍历第一个字符串b时因为符合删除条件,所以将该元素从数组中删除,并且将后一个元素移动(也就是第二个字符串b)至当前位置,导致下一次循环遍历时后一个字符串b并没有遍历到,所以无法删除。针对这种情况可以倒序删除的方式来避免:

1
2
3
4
5
6
7
8
9
10
11
public static void remove(ArrayList<String> list)
{
for(inti=list.size()-1;i>=0;i--)
{
Strings=list.get(i);
if(s.equals("b"))
{
list.remove(s);
}
}
}

因为数组倒序遍历时即使发生元素删除也不影响后序元素遍历。

接着解释一下实例二的错误原因。错误二产生的原因却是foreach写法是对实际的Iterable、hasNext、next方法的简写,问题同样处在上文的fastRemove方法中,可以看到第一行把modCount变量的值加一,但在ArrayList返回的迭代器(该代码在其父类AbstractList中):

1
2
3
public Iterator<E> iterator() {
return new Itr();
}

这里返回的是AbstractList类内部的迭代器实现private class Itr implements Iterator,看这个类的next方法:

1
2
3
4
5
6
7
8
9
10
11
public E next() {
checkForComodification();
try {
E next = get(cursor);
lastRet = cursor++;
return next;
} catch (IndexOutOfBoundsException e) {
checkForComodification();
throw new NoSuchElementException();
}
}

第一行checkForComodification方法:

1
2
3
4
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}

这里会做迭代器内部修改次数检查,因为上面的remove(Object)方法修改了modCount的值,所以才会报出并发修改异常。要避免这种情况的出现则在使用迭代器迭代时(显示或for-each的隐式)不要使用ArrayList的remove,改为用Iterator的remove即可。

1
2
3
4
5
6
7
8
9
10
11
12
public static void remove(ArrayList<String> list) 
{
Iterator<String> it = list.iterator();
while (it.hasNext())
{
String s = it.next();
if (s.equals("b"))
{
it.remove();
}
}
}

CopyOnWriteArrayList

Doug Lea 大师就为我们提供 CopyOnWriteArrayList 容器可以保证线程安全,保证读读之间在任何时候都不会被阻塞,CopyOnWriteArrayList 也被广泛应用于很多业务场景之中,CopyOnWriteArrayList 值得被我们好好认识一番。

CopyOnWrite的设计思想

如果简单的使用读写锁的话,在写锁被获取之后,读写线程被阻塞,只有当写锁被释放后读线程才有机会获取到锁从而读到最新的数据,站在读线程的角度来看,即读线程任何时候都是获取到最新的数据,满足数据实时性。既然我们说到要进行优化,必然有 trade-off,我们就可以牺牲数据实时性满足数据的最终一致性即可。而 CopyOnWriteArrayList 就是通过 Copy-On-Write(COW),即写时复制的思想来通过延时更新的策略来实现数据的最终一致性,并且能够保证读线程间不阻塞。

COW 通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行 Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。对 CopyOnWrite 容器进行并发的读的时候,不需要加锁,因为当前容器不会添加任何元素。所以 CopyOnWrite 容器也是一种读写分离的思想,延时更新的策略是通过在写的时候针对的是不同的数据容器来实现的,放弃数据实时性达到数据的最终一致性。

CopyOnWriteArrayList 的实现原理

现在我们来通过看源码的方式来理解 CopyOnWriteArrayList,实际上 CopyOnWriteArrayList 内部维护的就是一个数组

1
2
/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;

并且该数组引用是被 volatile 修饰,注意这里仅仅是修饰的是数组引用,其中另有玄机,稍后揭晓。关于 volatile 很重要的一条性质是它能够够保证可见性,关于 volatile 的详细讲解可以看这篇文章。对 list 来说,我们自然而然最关心的就是读写的时候,分别为 get 和 add 方法的实现。

get 方法实现原理

get 方法的源码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
public E get(int index) {
return get(getArray(), index);
}
/**
* Gets the array. Non-private so as to also be accessible
* from CopyOnWriteArraySet class.
*/
final Object[] getArray() {
return array;
}
private E get(Object[] a, int index) {
return (E) a[index];
}

可以看出来 get 方法实现非常简单,几乎就是一个“单线程”程序,没有对多线程添加任何的线程安全控制,也没有加锁也没有 CAS 操作等等,原因是,所有的读线程只是会读取数据容器中的数据,并不会进行修改。

add 方法实现原理

再来看下如何进行添加数据的?add 方法的源码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public boolean add(E e) {
final ReentrantLock lock = this.lock;
//1. 使用Lock,保证写线程在同一时刻只有一个
lock.lock();
try {
//2. 获取旧数组引用
Object[] elements = getArray();
int len = elements.length;
//3. 创建新的数组,并将旧数组的数据复制到新数组中
Object[] newElements = Arrays.copyOf(elements, len + 1);
//4. 往新数组中添加新的数据
newElements[len] = e;
//5. 将旧数组引用指向新的数组
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}

add 方法的逻辑也比较容易理解,请看上面的注释。需要注意这么几点:

  1. 采用 ReentrantLock,保证同一时刻只有一个写线程正在进行数组的复制,否则的话内存中会有多份被复制的数据;
  2. 前面说过数组引用是 volatile 修饰的,因此将旧的数组引用指向新的数组,根据 volatile 的 happens-before 规则,写线程对数组引用的修改对读线程是可见的。
  3. 由于在写数据的时候,是在新的数组中插入数据的,从而保证读写实在两个不同的数据容器中进行操作。

总结

我们知道 COW 和读写锁都是通过读写分离的思想实现的,但两者还是有些不同,可以进行比较:

COW vs 读写锁

相同点:1. 两者都是通过读写分离的思想实现;2.读线程间是互不阻塞的

不同点:对读线程而言,为了实现数据实时性,在写锁被获取后,读线程会等待或者当读锁被获取后,写线程会等待,从而解决“脏读”等问题。也就是说如果使用读写锁依然会出现读线程阻塞等待的情况。而 COW 则完全放开了牺牲数据实时性而保证数据最终一致性,即读线程对数据的更新是延时感知的,因此读线程不会存在等待的情况

对这一点从文字上还是很难理解,我们来通过 debug 看一下,add 方法核心代码为:

1
2
3
4
5
1.Object[] elements = getArray();
2.int len = elements.length;
3.Object[] newElements = Arrays.copyOf(elements, len + 1);
4.newElements[len] = e;
5.setArray(newElements);

假设 COW 的变化如下图所示:

http://static.cyblogs.com/QQ20200216-233236@2x.jpg

数组中已有数据 1,2,3,现在写线程想往数组中添加数据 4,我们在第 5 行处打上断点,让写线程暂停。读线程依然会“不受影响”的能从数组中读取数据,可是还是只能读到 1,2,3。如果读线程能够立即读到新添加的数据的话就叫做能保证数据实时性。当对第 5 行的断点放开后,读线程才能感知到数据变化,读到完整的数据 1,2,3,4,而保证数据最终一致性,尽管有可能中间间隔了好几秒才感知到。

这里还有这样一个问题: 为什么需要复制呢? 如果将 array 数组设定为 volitile 的, 对 volatile 变量写 happens-before 读,读线程不是能够感知到 volatile 变量的变化

原因是,这里 volatile 的修饰的仅仅只是数组引用数组中的元素的修改是不能保证可见性的。因此 COW 采用的是新旧两个数据容器,通过第 5 行代码将数组引用指向新的数组。

这也是为什么 concurrentHashMap 只具有弱一致性的原因,关于 concurrentHashMap 的弱一致性可以看这篇文章

COW 的缺点

CopyOnWrite 容器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要注意一下。

  1. 内存占用问题:因为 CopyOnWrite 的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对 象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对 象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比 如说 200M 左右,那么再写入 100M 数据进去,内存就会占用 300M,那么这个时候很有可能造成频繁的 minor GC 和 major GC。
  2. 数据一致性问题:CopyOnWrite 容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用 CopyOnWrite 容器。

参考文章

背景

​ 从你刚刚毕业开始最怕的问题就是乱码问题对不对?起码我是。后面渐渐的知道是编码问题,而后面为了出现这种问题就都选择UTF-8,然后后面渐渐的就开始淡忘了这个问题。然后当小弟弟小妹妹问我们这相关的问题的时候,也都是跟他们说,全部改成UTF-8就好了。

​ 但这是一种逃避,其实编码问题困扰我好多年,其实说句实话,真的没有搞懂。之前还有同事在一起相互考问 一个中文到底占用几个字节? 对不对,你遇到过吗?你回答的上来吗?哈哈

推荐几个常用的地址:

常见的编码

ASCII

​ 它是现今最早最通用的单字节编码系统,并等同于国际标准ISO/IEC 646,其中一个英文字母(不分大小写)占一个字节的空间。

引申:字节是指一小组相邻的二进制数码。通常是8位作为一个字节,如00001111,换算为十进制。

最小值:-128

最大值:127

标准ASCII 码也叫基础ASCII码,使用7 位二进制数(剩下的1位二进制为0)来表示所有的大写和小写字母,数字0 到9、标点符号, 以及在美式英语中使用的特殊控制字符。其中:

  • 1、0~31及127(共33个)是控制字符或通信专用字符(其余为可显示字符),如控制符:LF(换行)、CR(回车)、FF(换页)、DEL(删除)、BS(退格)、BEL(响铃)等;通信专用字符:SOH(文头)、EOT(文尾)、ACK(确认)等;ASCII值为8、9、10 和13 分别转换为退格、制表、换行和回车字符。它们并没有特定的图形显示,但会依不同的应用程序,而对文本显示有不同的影响。

  • 2、32~126(共95个)是字符(32是空格),其中48~57为0到9十个阿拉伯数字。

  • 3、65~90为26个大写英文字母,97~122号为26个小写英文字母,其余为一些标点符号、运算符号等。

同时还要注意,在标准ASCII中,其最高位(b7)用作奇偶校验位。所谓奇偶校验,是指在代码传送过程中用来检验是否出现错误的一种方法,一般分奇校验和偶校验两种。奇校验规定:正确的代码一个字节中1的个数必须是奇数,若非奇数,则在最高位b7添1;偶校验规定:正确的代码一个字节中1的个数必须是偶数,若非偶数,则在最高位b7添1。

UTF-8

​ UTF-8 是目前互联网上使用最广泛的一种 Unicode 编码方式,它的最大特点就是可变长。它可以使用 1 - 4 个字节表示一个字符,根据字符的不同变换长度。编码规则如下:

  • 1、对于单个字节的字符,第一位设为 0,后面的 7 位对应这个字符的 Unicode 码点。因此,对于英文中的 0 - 127 号字符,与 ASCII 码完全相同。这意味着 ASCII 码那个年代的文档用 UTF-8 编码打开完全没有问题。

  • 2、对于需要使用 N 个字节来表示的字符(N > 1),第一个字节的前 N 位都设为 1,第 N + 1 位设为0,剩余的 N - 1 个字节的前两位都设位 10,剩下的二进制位则使用这个字符的 Unicode 码点来填充。

序号 Unicode UTF-8
1 0000 0000 - 0000 007F 0xxxxxxx
2 0000 0080 - 0000 07FF 110xxxxx 10xxxxxx
3 0000 0800 - 0000 FFFF 1110xxxx 10xxxxxx 10xxxxxx
4 0001 0000 - 0010 FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

下面以汉字“汉”为利,具体说明如何进行 UTF-8 编码和解码,大家就很容易理解了。

“汉”的 Unicode 码点是 0x6c49(110 1100 0100 1001),通过上面的对照表可以发现,0x0000 6c49 位于第三行的范围,那么得出其格式为 1110xxxx 10xxxxxx 10xxxxxx。接着,从“汉”的二进制数最后一位开始,从后向前依次填充对应格式中的 x,多出的 x 用 0 补上。这样,就得到了“汉”的 UTF-8 编码为 11100110 10110001 10001001,转换成十六进制就是 0xE6 0xB7 0x89

UTF-16

​ 在了解 UTF-16 编码方式之前,先了解一下另外一个概念——“平面”。

​ 在上面的介绍中,提到了 Unicode 是一本很厚的字典,她将全世界所有的字符定义在一个集合里。这么多的字符不是一次性定义的,而是分区定义。每个区可以存放 65536 个(216216)字符,称为一个平面(plane)。目前,一共有 17 个(2525)平面,也就是说,整个 Unicode 字符集的大小现在是 221221。

​ 最前面的 65536 个字符位,称为基本平面(简称 BMP ),它的码点范围是从 0 到 216−1216−1,写成 16 进制就是从 U+0000 到 U+FFFF。所有最常见的字符都放在这个平面,这是 Unicode 最先定义和公布的一个平面。剩下的字符都放在辅助平面(简称 SMP ),码点范围从 U+010000 到 U+10FFFF。

​ 基本了解了平面的概念后,再说回到 UTF-16UTF-16 编码介于 UTF-32UTF-8 之间,同时结合了定长和变长两种编码方法的特点。它的编码规则很简单:基本平面的字符占用 2 个字节,辅助平面的字符占用 4 个字节。也就是说,UTF-16 的编码长度要么是 2 个字节(U+0000 到 U+FFFF),要么是 4 个字节(U+010000 到 U+10FFFF)。那么问题来了,当我们遇到两个字节时,到底是把这两个字节当作一个字符还是与后面的两个字节一起当作一个字符呢?

​ 这里有一个很巧妙的地方,在基本平面内,从 U+D800 到 U+DFFF 是一个空段,即这些码点不对应任何字符。因此,这个空段可以用来映射辅助平面的字符。

​ 辅助平面的字符位共有 220220 个,因此表示这些字符至少需要 20 个二进制位。UTF-16 将这 20 个二进制位分成两半,前 10 位映射在 U+D800 到 U+DBFF,称为高位(H),后 10 位映射在 U+DC00 到 U+DFFF,称为低位(L)。这意味着,一个辅助平面的字符,被拆成两个基本平面的字符表示。

​ 因此,当我们遇到两个字节,发现它的码点在 U+D800 到 U+DBFF 之间,就可以断定,紧跟在后面的两个字节的码点,应该在 U+DC00 到 U+DFFF 之间,这四个字节必须放在一起解读。

Unicode之Emoji表情

1999年前后,日本一个名叫栗田穰崇的年轻人,和许多直男一样, 给女友发的短信经常会被误解。比如,“知道了”被解读成“生气了”、“不耐烦了”,随后引发冷战。 于是少年栗田想:“如果能在文字里插入一些表情符号来表达感情,大家应该会需要吧!”
原始的Emoji就这么诞生了。

Emoji字符是Unicode字符集中一部分. 特定形象的Emoji表情符号对应到特定的Unicode字节。
常见的Emoji表情符号在Unicode字符集中的范围和具体的字节映射关系, 可通过Emoji Unicode Tables (http://apps.timwhitlock.info/emoji/tables/unicode#block-6c-other-additional-symbols)查看到。

对于做UGC的网站来说,现在不仅仅是苹果手机,越来越多的软件都在用emoji表情,因为它能很形象的来表达我们的感情。对于这里我们能做的可以是把emoji表情转码后用文本的方式存在数据库中,还有一个方式就是升级数据库,改变它的编码。

推荐一个对这个错误描述的地址: https://blog.csdn.net/asahinokawa/article/details/85255732

一个汉字占几个字符?

如果你说的“字符”就是指 Java 中的 char,那好,那它就是 16 位,2 字节。

如果你说的“字符”是指我们用眼睛看到的那些“抽象的字符”,那么,谈论它占几个字节是没有意义的。具体地讲,脱离具体的编码谈某个字符占几个字节是没有意义的。就好比有一个抽象的整数“42”,你说它占几个字节?这得具体看你是用 byteshortint,还是 long 来存它。用 byte 存就占一字节,用 short 存就占两字节,int 通常是四字节,long 通常八字节。当然,如果你用 byte,受限于它有限的位数,有些数它是存不了的,比如 256 就无法放在一个 byte 里了。

字符是同样的道理,如果你想谈“占几个字节”,就要先把编码说清楚。同一个字符在不同的编码下可能占不同的字节。就以你举的“字”字为例,“字”在 GBK 编码下占 2 字节,在 UTF-16 编码下也占 2 字节,在 UTF-8 编码下占 3 字节,在 UTF-32 编码下占 4 字节。不同的字符在同一个编码下也可能占不同的字节。“字”在 UTF-8 编码下占3字节,而“A”在 UTF-8 编码下占 1 字节。(因为 UTF-8 是变长编码),而 Java 中的 char 本质上是 UTF-16 编码。而 UTF-16 实际上也是一个变长编码(2 字节或 4字节)。

如果一个抽象的字符在 UTF-16 编码下占 4 字节,显然它是不能放到 char 中的。换言之, char 中只能放 UTF-16 编码下只占 2 字节的那些字符。

参考地址

作者:薛8

来源:https://juejin.im/post/5c890b4a51882501351d5929

AQS简介

AbstractQueuedSynchronizer抽象队列同步器,简称为AQS,可用于构建阻塞锁或者其他相关同步器的基础框,是Java并发包的基础工具类。通过AQS这个框架可以对同步状态原子性管理、线程的阻塞和解除阻塞、队列的管理进行统一管理。
AQS是抽象类,并不能直接实例化,当需要使用AQS的时候需要继承AQS抽象类并且重写指定的方法,这些重写方法包括线程获取资源和释放资源的方式(如ReentractLock通过分别重写线程获取和释放资源的方式实现了公平锁和非公平锁),同时子类还需要负责共享变量state的维护,如当state为0时表示该锁没有被占,大于0时候代表该锁被一个或多个线程占领(重入锁),而队列的维护(获取资源失败入队、线程唤醒、线程的状态等)不需要我们考虑,AQS已经帮我们实现好了。AQS的这种设计模式采用的正是模板方法模式
总结起来子类的任务有:

  1. 通过CAS操作维护共享变量state
  2. 重写资源的获取方式。
  3. 重写资源释放的方式。

如果对CAS和Java内存模型还不清楚的,建议先了解这两者之后再食用本文,效果更佳!CAS原理分析及ABA问题详解 什么是Java内存模型?

完成以上三个任务即可实现自己的锁。
AQS作为J.U.C的工具类,面向的是需要实现锁的实现者,而锁面向的是锁的使用者,这两者的区别还是需要搞清楚的。

AQS数据结构

先看AQS有哪些重要的成员变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 头结点,你直接把它当做 当前持有锁的线程 可能是最好理解的
private transient volatile Node head;

// 阻塞的尾节点,每个新的节点进来,都插入到最后,也就形成了一个链表
private transient volatile Node tail;

// 这个是最重要的,不过也是最简单的,代表当前锁的状态,0代表没有被占用,大于0代表有线程持有当前锁
// 之所以说大于0,而不是等于1,是因为锁可以重入嘛,每次重入都加上1
private volatile int state;

// 代表当前持有独占锁的线程,举个最重要的使用例子,因为锁可以重入
// reentrantLock.lock()可以嵌套调用多次,所以每次用这个来判断当前线程是否已经拥有了锁
// if (currentThread == getExclusiveOwnerThread()) {state++}
private transient Thread exclusiveOwnerThread; //继承自AbstractOwnableSynchronizer

然后再看看AQS的内部结构,AQS内部数据结构为一个双向链表和一个单向链表,双链表为同步队列,队列中的每个节点对应一个Node内部类,AQS通过控制链表的节点而达到阻塞、同步的目的,单链表为条件队列,可以把同步队列和条件队列理解成储存等待状态的线程的队列,但是条件队列中的线程并不能直接去获取资源,而要先从条件队列转到同步队列中排队获取,同步队列的唤醒结果是线程去尝试获取锁,而条件队列的唤醒结果是把线程从条件队列移到同步队列中,一个线程要么是在同步队列中,要么是在条件队列中,不可能同时存在这两个队列里面。

http://static.cyblogs.com/169ae1c4f86ba568.png

Java阻塞状态等待状态的线程从Linux内核来看,都是阻塞(等待)状态,它们都会让出CPU时间片。Java为了方便管理线程将“阻塞(等待)”状态细分成了阻塞状态和等待状态,这两个状态的区别在于由谁去唤醒,是操作系统还是其他线程。Java线程请求某一个资源失败的时候就会进入阻塞状态,处于阻塞态的线程会不断请求资源,一旦请求成功,就会进入就绪队列,等待执行。而当线程调用waitjoinpack函数时候会进入等待状态,需要其它线程显性的唤醒否则会无限期的处于等待状态。

Java线程6状态图:

http://static.cyblogs.com/169ae1c4f87a969e.png

内部类Node详解:

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
static final class Node {
//代表当前节(线程)点是共享模式
static final Node SHARED = new Node();
//代表当前节点(线程)是独占模式
static final Node EXCLUSIVE = null;
//代表当前节点(线程)已被取消
static final int CANCELLED = 1;
//代表当前节点(线程)的后继节点需要被提醒唤醒
static final int SIGNAL = -1;
//代表节点(线程)在 Condition queue中,等待某一条件
static final int CONDITION = -2;
//代表当前节点的后继节点(线程)会传传播唤醒的操作,仅在共享模式下才有作用
static final int PROPAGATE = -3;
//代表当前节点的状态,它的取值除了以上说的CANCELLED、SIGNAL、CONDITION、PROPAGATE,同时
//还可能为0,为0的时候代表当前节点在sync队列中,阻塞着排队获取锁。
volatile int waitStatus;
//当前节点的前驱节点
volatile Node prev;
//当前节点的后继节点
volatile Node next;
//当前节点关联的线程
volatile Thread thread;
//在condition队列中的后继节点
Node nextWaiter;

//判断当前节点是否为共享模式
final boolean isShared() {
return nextWaiter == SHARED;
}

//返回当前节点的前驱节点 没有前驱节点则抛出异常
final Node predecessor() throws NullPointerException {
Node p = prev;
if (p == null)
throw new NullPointerException();
else
return p;
}
}

每个线程都关联一个节点,节点的状态也代表着线程的状态,AQS通过对同步队列的管理而达到对线程的管理。

AQS的功能

AQS提供了2大功能,基于双链表的同步队列和基于单链表的条件队列,同步队列维护的是阻塞状态的线程对应的节点,这些线程都是阻塞着排队获取锁的,条件队列维护的是等待状态的线程对应的节点

同步队列

AQS提供了两种方式去获取资源,分别是共享模式独占模式,但是一般锁只会去继承其中一种模式,不会在一个锁里同时存在共享模式独占模式两种模式。

资源指锁、IO、Socket等

当一个线程以共享模式或独占模式去获取资源的时候,如果获取失败则将该线程封装成Node节点(同时将该节点标识为共享模式或独占模式)加入到同步队列的尾部AQS实时维护着这个同步队列,这个队列以FIFO(先进先出)来管理节点的排队,即资源的转移(获取再释放)的顺序是从头结点开始到尾节点。

http://static.cyblogs.com/169ae1c4f8d7e8d6.png

共享模式和独占模式去获取、释放资源都分别对应着一套API,以下分别分析这两套API

独占模式即获取资源的排他锁,共享模式及获取资源的共享锁

独占模式

独占模式即一个线程获取到资源后,其他线程不能再对资源进行任何操作,只能阻塞获得资源。

获取资源
  1. 线程调用子类重写的tryAcquire方法获取资源,如果获取成功,则流程结束,否则继续往下执行。
  2. 调用addWaiter方法(详细过程看下面的源码解析),将该线程封装成Node节点,并添加到队列队尾
  3. 调用acquireQueued方法让节点以”死循环”方式进行获取资源,为什么死循环加了双引号呢?因为循环并不是一直让节点无间断的去获取资源,节点会经历 获取资源->失败->线程进入等待状态->唤醒->获取资源……,线程在死循环的过程会不断等待和唤醒,节点进入到自旋状态(详细过程看下面的源码解析),再循环过程中还会将标识为取消的前驱节点移除队列,同时标识前驱节点状态为SIGNAL
  4. 线程的等待状态是通过调用LockSupport.lock()方法实现的,这个方法会响应Thread.interrupt,但是不会抛出InterruptedException异常,这点与Thread.sleepThread.wait不一样。

http://static.cyblogs.com/169ae1c4f8c40a95.png

http://static.cyblogs.com/169ae1c4f8e8cde5.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
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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
public final void acquire(int arg) {
//该线程调用tryAcquire方法尝试以独占模式获取资源,如果获取失败,则调
//用addWaiter函数,将线程封装到Node节点中,然后再将Node节点加入到同
//步队列的尾部,然后再调用acquireQueued让线程进入到阻塞状态,如果获
//取成功则返回true,然后调用selfInterrupt
//函数。
//注意的是,tryAcquire函数就是继承AQS的子类所需要去重写的方法。
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE),arg))
selfInterrupt();
}

//AQS的tryAcquire函数并没有获取资源的相关实现,需要继承`AQS`的子类去
//重写这个方法。
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}

private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
//创建新的节点,并将线程和节点关联。
//将同步队列的尾节点后继节点指向新节点,
//将新节点的前驱节点指向尾节点,
//新节点称为同步队列的尾节点。
if (pred != null) {
node.prev = pred;
//CAS操作将新节点插入到,成功则返回,不成功则继续下面的enq方法,
//进行死循环CAS插入,直到成功。
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
//如果上面的CAS操作插入不成功,则调用enq方法 死循环插入 直到成功。
enq(node);
return node;
}

private Node enq(final Node node) {
//死循环 直到插入成功。
for (;;) {
Node t = tail;
//如果尾节点为null,说明同步队列还未初始化,则CAS操作新建头节点
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
//通过CAS操作将节点插入到同步队列尾部
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}

//节点以“死循环”的方式去获取资源,为什么死循环加了双引号呢?因为循环并不
//是一直让节点无间断的去获取资源,节点会经历 获取资源->失败->线程进入等待
//状态->唤醒->获取资源......,线程在死循环的过程会不断等待和唤醒,即节点的自旋。
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
//获取节点的前驱节点
final Node p = node.predecessor();
//如果前驱节点为头结点,表示资源正在前驱节点的手上,那该节点
//去尝试获取资源,如果获取成功则将该节点设置为头结点,并且
//返回。
//如果该节点的前驱节点并不是头节点或者是前驱节点是头结点但是
//该节点获取资源失败,继续往下执行。
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
//调用shouldParkAfterFailedAcquire函数,将该节点的前驱节点
//的状态设置为SIGNAL,告诉前驱节点我要去“睡觉”了,当资源排
//到你的时候,你就通知我一下让我醒来,即节点做进入等待状态
//的准备。
//当节点做好了进入等待状态的准备,则调用parkAndCheckInterrupt
//函数,让该节点进入到等待状态。
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
//获取前驱节点的状态。
int ws = pred.waitStatus;
//如果前驱节点的状态已经为SIGNAL了,即已经做好准备了,那直接返回。
if (ws == Node.SIGNAL)
/*
* This node has already set status asking a release
* to signal it, so it can safely park.
*/
return true;
//如果前驱节点的状态为取消状态,则将前驱节点移除队列,循环这个过程
//直到前驱节点不为取消状态为止。
if (ws > 0) {
/*
* Predecessor was cancelled. Skip over predecessors and
* indicate retry.
*/
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
//如果前驱节点没有做好准备(标志状态为SIGNAL)、前驱节点也没有被取消,
//则使用CAS操作将前驱节点的状态更新为SIGNAL,然后返回false,为什么
//是返回false呢?因为CAS操作并不保证一定能更新成功,返回false的目的
//是让acquireQueued函数再执行一次for循环,这个循环第一可以让该节点
//再尝试获取资源(万一成功了呢 是吧),第二是让acquireQueued函数再调用
//一次shouldParkAfterFailedAcquire函数(即本函数)判断节点的前驱节点是
//否已经设置为SIGNAL状态了。
} else {
/*
* waitStatus must be 0 or PROPAGATE. Indicate that we
* need a signal, but don't park yet. Caller will need to
* retry to make sure it cannot acquire before parking.
*/
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}

//调用LockSupport.park函数将该线程设置为等待状态
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
//注意LockSupport遇到Thread.interrupt是会立刻返回的,但是不会抛出异常InterruptedExcept
//ion,这个需要注意和Thread.wait,Thread.sleep的区别,
//唤醒的时候 会返回该线程是否为中断唤醒的。
return Thread.interrupted();
}
释放资源
  1. 线程调用子类重写的tryRelease方法进行释放资源,如果释放成功则继续检查线程(节点)的是否有后继节点,有后继几点则去唤醒
  2. 调用unparkSuccessor方法进行后继节点的唤醒,如果后继节点为取消状态,则从队列的队尾往前遍历,找到一个离节点最近且不为取消状态的节点进行唤醒,如果后继节点不为取消状态则直接唤醒

http://static.cyblogs.com/169ae1c515d4bfe8.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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public final boolean release(int arg) {
//线程调用tryRelease方法尝试释放资源,如果释放成功则检查该节点是否有后继节点,有的话则
//调用unpacrkSuccessor()方法去唤醒后继节点。
//注意的是,tryRelease函数就是继承AQS的子类所需要去重写的方法。
if (tryRelease(arg)) {
Node h = head;
//头结点(即释放资源的节点)不为空,头结点的状态不为0,代表有后继节点,需要唤醒。
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}

private void unparkSuccessor(Node node) {
/*
* If status is negative (i.e., possibly needing signal) try
* to clear in anticipation of signalling. It is OK if this
* fails or if status is changed by waiting thread.
*/
//获取头结点状态。
int ws = node.waitStatus;
//如果状态小于0,即代表有后继节点需要唤醒。
if (ws < 0)
//将头结点的状态置为0 因为只需要唤醒一次
compareAndSetWaitStatus(node, ws, 0);

/*
* Thread to unpark is held in successor, which is normally
* just the next node. But if cancelled or apparently null,
* traverse backwards from tail to find the actual
* non-cancelled successor.
*/
Node s = node.next;
//如果头结点的后继节点为空 或者 头结点的后继节点处于取消状态,则从尾部开始往前寻找,
//找到一个离头结点最近 且状态不是取消状态的节点。
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
//如果头结点的后继节点不为取消状态,则直接将后继节点唤醒
if (s != null)
LockSupport.unpark(s.thread);
}
共享模式

共享模式下,线程无论是获取资源还是释放资源,都可能会唤醒后继节点

获取资源
  1. 调用子类重写的tryAcquireShared方法进行资源获取,获取失败则调用doAcquireShared线程封装Node节点加入到同步队列队尾
  2. 调用doAcquireShared方法让节点以”死循环”方式进行获取资源,为什么死循环加了双引号呢?因为循环并不是一直让节点无间断的去获取资源,节点会经历获取资源->失败->线程进入等待状态->唤醒->获取资源……,线程在死循环的过程会不断等待和唤醒,节点进入到自旋状态(详细过程看下面的源码解析)。如果线程节点被唤醒后,且获取资源成功,且后继节点为共享模式,那么会唤醒后继节点……唤醒会一直传递下去,直到后继节点不是共享模式,唤醒的节点同样会去获取资源,这点和独占模式不一样。

http://static.cyblogs.com/169ae1c51e6294d8.png

http://static.cyblogs.com/169ae1c520e21e80.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
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
public final void acquireShared(int arg) {
//和独占模式的一样,同样是调用子类重写的tryAcquireShared方法以共享模式进行资源获取。
//如果获取失败,则调用doAcquireShared方法将线程封装成Node节点加入到同步队列的队尾,
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}

protected int tryAcquireShared(int arg) {
throw new UnsupportedOperationException();
}

private void doAcquireShared(int arg) {
//将线程封装到节点中,且将节点加入到队尾中。
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
//获取线程(节点)的前驱节点。
final Node p = node.predecessor();
//如果前驱节点为头结点,则该线程尝试获取资源。
if (p == head) {
//获取资源。
int r = tryAcquireShared(arg);
//获取资源成功则将节点设为头结点。
if (r >= 0) {
//获取成功 对后继SHARED节点持续唤醒
setHeadAndPropagate(node, r);
p.next = null; // help GC
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
//和独占模式的一样。
//调用shouldParkAfterFailedAcquire函数,将该节点的前驱节点
//的状态设置为SIGNAL,告诉前驱节点我要去“睡觉”了,当资源排
//到你的时候,你就通知我一下让我醒来,即节点做进入等待状态的准备。
//当节点做好了进入等待状态的准备,则调用parkAndCheckInterrupt
//函数,让该节点进入到等待状态。
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}

private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below
setHead(node);
/*
* Try to signal next queued node if:
* Propagation was indicated by caller,
* or was recorded (as h.waitStatus either before
* or after setHead) by a previous operation
* (note: this uses sign-check of waitStatus because
* PROPAGATE status may transition to SIGNAL.)
* and
* The next node is waiting in shared mode,
* or we don't know, because it appears null
*
* The conservatism in both of these checks may cause
* unnecessary wake-ups, but only when there are multiple
* racing acquires/releases, so most need signals now or soon
* anyway.
*/
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
//如果节点为共享节点,则调用doReleaseShared函数唤醒后继节点。
if (s == null || s.isShared())
doReleaseShared();
}
}
释放资源
  1. 调用子类重写的tryReleaseShared方法释放资源,释放成功则调用doReleaseShared方法进行后继节点的唤醒。
  2. 如果后继节点为共享模式,则持续唤醒。

http://static.cyblogs.com/169ae1c5271b53d9.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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
//调用子类重写的tryReleaseShared方法进行以共享模式释放资源,释放失败则调用doReleaseShared。
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}

protected boolean tryReleaseShared(int arg) {
throw new UnsupportedOperationException();
}

private void doReleaseShared() {
/*
* Ensure that a release propagates, even if there are other
* in-progress acquires/releases. This proceeds in the usual
* way of trying to unparkSuccessor of head if it needs
* signal. But if it does not, status is set to PROPAGATE to
* ensure that upon release, propagation continues.
* Additionally, we must loop in case a new node is added
* while we are doing this. Also, unlike other uses of
* unparkSuccessor, we need to know if CAS to reset status
* fails, if so rechecking.
*/
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
//如果节点标识后继节点需要唤醒,则调用unparkSuccessor方法进行唤醒。
if (ws == Node.SIGNAL) {
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}

条件队列

条件队列又称等待队列、条件队列等,条件队列的实现是通过ConditionObject的内之类来完成的,,一开始就介绍了同步队列条件队列的去,不过这里再啰嗦一下,可以把同步队和条件队列理解成储存等待状态的线程的队列,条件队列中的线程并不能直接去获取资源,而要先从条件队列转到同步队列中排队获取,一个线程要么是在同步队列中,要么是在条件队列中,不可能同时存在这两个队列里面。

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
/* 
* 使当前线程进入等待状态,直到以下4种情况任意一个发生:
* 1.另一个线程调用该对象的signal(),当前线程恰好是被选中的唤醒线程
* 2.另一个线程调用该对象的signalAll()
* 3.另一个线程interrupt当前线程(此时会抛出InterruptedException)
* 4.虚假唤醒(源自操作系统,发生概率低)
* ConditionObject要求调用时该线程已经拿到了其外部AQS类的排它锁(acquire成功)
*/
void await() throws InterruptedException;
/*
* 与await()相同,但是不会被interrupt唤醒
*/
void awaitUninterruptibly();
/*
* 与await()相同,增加了超时时间,超过超时时间也会停止等待
* 三个方法功能相似,其返回值代表剩余的超时时间,或是否超时
*/
long awaitNanos(long nanosTimeout) throws InterruptedException;
boolean await(long time, TimeUnit unit) throws InterruptedException;
boolean awaitUntil(Date deadline) throws InterruptedException;
/*
* 唤醒一个正在等待该条件变量对象的线程
* ConditionObject会选择等待时间最长的线程来唤醒
* ConditionObject要求调用时该线程已经拿到了其外部AQS类的排它锁(acquire成功)
*/
void signal();
/*
* 唤醒所有正在等待该条件变量对象的线程
* ConditionObject要求调用时该线程已经拿到了其外部AQS类的排它锁(acquire成功)
*/
void signalAll();

可以看到,其作用与Object原生的wait()/notify()/notifyAll()很相似,但是增加了更多的功能。下面以awaitUninterruptibly()、signal()为例,阐述一下其内部实现。

http://static.cyblogs.com/169ae1c528ceaba6.png

同步队列和条件队列的关系

线程执行condition.await()方法,将节点1从同步队列转移到条件队列中。

http://static.cyblogs.com/169b3432b128105d.png

线程执行condition.signal()方法,将节点1从条件队列中转移到同步队列。

http://static.cyblogs.com/169b3411b74cfff1.png

基本概念

1、什么是序列化和反序列化

(1)Java序列化是指把Java对象转换为字节序列的过程,而Java反序列化是指把字节序列恢复为Java对象的过程;

(2)序列化:对象序列化的最主要的用处就是在传递和保存对象的时候,保证对象的完整性和可传递性。序列化是把对象转换成有序字节流,以便在网络上传输或者保存在本地文件中。序列化后的字节流保存了Java对象的状态以及相关的描述信息。序列化机制的核心作用就是对象状态的保存与重建。

(3)反序列化:客户端从文件中或网络上获得序列化后的对象字节流后,根据字节流中所保存的对象状态及描述信息,通过反序列化重建对象。

(4)本质上讲,序列化就是把实体对象状态按照一定的格式写入到有序字节流,反序列化就是从有序字节流重建对象,恢复对象状态。

2、为什么需要序列化与反序列化

我们知道,当两个进程进行远程通信时,可以相互发送各种类型的数据,包括文本、图片、音频、视频等, 而这些数据都会以二进制序列的形式在网络上传送。

那么当两个Java进程进行通信时,能否实现进程间的对象传送呢?答案是可以的!如何做到呢?这就需要Java序列化与反序列化了!

换句话说,一方面,发送方需要把这个Java对象转换为字节序列,然后在网络上传送;另一方面,接收方需要从字节序列中恢复出Java对象。

当我们明晰了为什么需要Java序列化和反序列化后,我们很自然地会想Java序列化的好处。其好处一是实现了数据的持久化,通过序列化可以把数据永久地保存到硬盘上(通常存放在文件里),二是,利用序列化实现远程通信,即在网络上传送对象的字节序列。

总的来说可以归结为以下几点:

(1)永久性保存对象,保存对象的字节序列到本地文件或者数据库中;
(2)通过序列化以字节流的形式使对象在网络中进行传递和接收;
(3)通过序列化在进程间传递对象;

3、序列化算法一般会按步骤做如下事情:

(1)将对象实例相关的类元数据输出。
(2)递归地输出类的超类描述直到不再有超类。
(3)类元数据完了以后,开始从最顶层的超类开始输出对象实例的实际数据值。
(4)从上至下递归输出实例的数据

Java如何实现序列化和反序列化

1、JDK类库中序列化和反序列化API

(1)java.io.ObjectOutputStream:表示对象输出流;

它的writeObject(Object obj)方法可以对参数指定的obj对象进行序列化,把得到的字节序列写到一个目标输出流中;

(2)java.io.ObjectInputStream:表示对象输入流;

它的readObject()方法源输入流中读取字节序列,再把它们反序列化成为一个对象,并将其返回;

2、实现序列化的要求

只有实现了Serializable或Externalizable接口的类的对象才能被序列化,否则抛出异常!

3、实现Java对象序列化与反序列化的方法

假定一个User类,它的对象需要序列化,可以有如下三种方法:

(1)若User类仅仅实现了Serializable接口,则可以按照以下方式进行序列化和反序列化

ObjectOutputStream采用默认的序列化方式,对User对象的非transient的实例变量进行序列化。
ObjcetInputStream采用默认的反序列化方式,对对User对象的非transient的实例变量进行反序列化。

(2)若User类仅仅实现了Serializable接口,并且还定义了readObject(ObjectInputStream in)和writeObject(ObjectOutputSteam out),则采用以下方式进行序列化与反序列化。

ObjectOutputStream调用User对象的writeObject(ObjectOutputStream out)的方法进行序列化。
ObjectInputStream会调用User对象的readObject(ObjectInputStream in)的方法进行反序列化。

(3)若User类实现了Externalnalizable接口,且User类必须实现readExternal(ObjectInput in)和writeExternal(ObjectOutput out)方法,则按照以下方式进行序列化与反序列化。

ObjectOutputStream调用User对象的writeExternal(ObjectOutput out))的方法进行序列化。
ObjectInputStream会调用User对象的readExternal(ObjectInput in)的方法进行反序列化。

4、JDK类库中序列化的步骤

步骤一:创建一个对象输出流,它可以包装一个其它类型的目标输出流,如文件输出流:

1
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("D:\\object.out"));

步骤二:通过对象输出流的writeObject()方法写对象:

1
oos.writeObject(new User("xuliugen", "123456", "male"));
5、JDK类库中反序列化的步骤

步骤一:创建一个对象输入流,它可以包装一个其它类型输入流,如文件输入流:

1
ObjectInputStream ois= new ObjectInputStream(new FileInputStream("object.out"));

步骤二:通过对象输出流的readObject()方法读取对象:

1
User user = (User) ois.readObject();

说明:为了正确读取数据,完成反序列化,必须保证向对象输出流写对象的顺序与从对象输入流中读对象的顺序一致。

6、序列化和反序列化的示例

为了更好地理解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
public class SerialDemo {

public static void main(String[] args) throws IOException, ClassNotFoundException {
//序列化
FileOutputStream fos = new FileOutputStream("object.out");
ObjectOutputStream oos = new ObjectOutputStream(fos);
User user1 = new User("xuliugen", "123456", "male");
oos.writeObject(user1);
oos.flush();
oos.close();
//反序列化
FileInputStream fis = new FileInputStream("object.out");
ObjectInputStream ois = new ObjectInputStream(fis);
User user2 = (User) ois.readObject();
System.out.println(user2.getUserName() + " " + user2.getPassword() + " " + user2.getSex());
//反序列化的输出结果为:xuliugen 123456 male
}
}
public class User implements Serializable {
private String userName;
private String password;
private String sex;
//全参构造方法、get和set方法省略
}

object.out文件如下(使用UltraEdit打开):

http://static.cyblogs.com/20180407130515728.png

注:上图中0000000h-000000c0h表示行号;0-f表示列;行后面的文字表示对这行16进制的解释;对上述字节码所表述的内容感兴趣的可以对照相关的资料,查阅一下每一个字符代表的含义,这里不在探讨!

类似于我们Java代码编译之后的.class文件,每一个字符都代表一定的含义。序列化和反序列化的过程就是生成和解析上述字符的过程!

序列化图示:

http://static.cyblogs.com/20180408163613978.jpeg

反序列化图示:

http://static.cyblogs.com/20180408163634701.jpeg

相关注意事项

1、序列化时,只对对象的状态进行保存,而不管对象的方法;

2、当一个父类实现序列化,子类自动实现序列化,不需要显式实现Serializable接口;

3、当一个对象的实例变量引用其他对象,序列化该对象时也把引用对象进行序列化;

4、并非所有的对象都可以序列化,至于为什么不可以,有很多原因了,比如:

  • 安全方面的原因,比如一个对象拥有private,public等field,对于一个要传输的对象,比如写到文件,或者进行RMI传输等等,在序列化进行传输的过程中,这个对象的private等域是不受保护的;
  • 资源分配方面的原因,比如socket,thread类,如果可以序列化,进行传输或者保存,也无法对他们进行重新的资源分配,而且,也是没有必要这样实现;

5、声明为static和transient类型的成员数据不能被序列化。因为static代表类的状态,transient代表对象的临时数据。

6、序列化运行时使用一个称为 serialVersionUID 的版本号与每个可序列化类相关联,该序列号在反序列化过程中用于验证序列化对象的发送者和接收者是否为该对象加载了与序列化兼容的类。为它赋予明确的值。显式地定义serialVersionUID有两种用途:

  • 在某些场合,希望类的不同版本对序列化兼容,因此需要确保类的不同版本具有相同的serialVersionUID;
  • 在某些场合,不希望类的不同版本对序列化兼容,因此需要确保类的不同版本具有不同的serialVersionUID。

7、Java有很多基础类已经实现了serializable接口,比如String,Vector等。但是也有一些没有实现serializable接口的;

8、如果一个对象的成员变量是一个对象,那么这个对象的数据成员也会被保存!这是能用序列化解决深拷贝的重要原因;

​ 序列化时,类的所有数据成员应可序列化除了声明为transient或static的成员。将变量声明为transient告诉JVM我们会负责将变元序列化。将数据成员声明为transient后,序列化过程就无法将其加进对象字节流中,没有从transient数据成员发送的数据。后面数据反序列化时,要重建数据成员(因为它是类定义的一部分),但不包含任何数据,因为这个数据成员不向流中写入任何数据。记住,对象流不序列化static或transient。我们的类要用writeObject()与readObject()方法以处理这些数据成员。使用writeObject()与readObject()方法时,还要注意按写入的顺序读取这些数据成员

那对于这些问题,我们该如何进行序列化和反序列化呢?

简单,也就是说我们要对这俩个类型的变量单独处理,怎么办?就是在出现这类变量的所属类中增加俩个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private void writeObject(java.io.ObjectOutputStream out)throws IOException

private void readObject(java.io.ObjectInputStream in)throws IOException,ClassNotFoundException;
而对应于我们的类中添加的方法就是

public class SerialTest extends parent implements Serializable {
//省略
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
out.writeInt(this.testStatic);
out.writeInt(this.testTransient);
}

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
this.testStatic = in.readInt();
this.testTransient = in.readInt();
}
}

当ObjectOutputStream对一个SerialTest对象进行序列化时,如果该对象具有writeObject()方法,那么就会执行这一方法,否则就按默认方式序列化。在该对象的writeObjectt()方法中,可以先调用ObjectOutputStream的defaultWriteObject()方法,使得对象输出流先执行默认的序列化操作。同理可得出反序列化的情况,不过这次是defaultReadObject()方法。

ObjectOutputStream.defaultWriteObject() :将当前类的非静态(static)和非瞬态字段(transient)写入此流。

ObjectInputStream.defaultReadObject() : 从此流读取当前类的非静态和非瞬态字段。

Externalizable的作用

对于实现Serializable的类来说,在序列化的时候,所有的非静态(static)和非瞬态字段(transient)会被自动序列化,如果有一些特殊要求,我们可以完全手动控制哪些字段要被序列化,哪些不要序列化。将他们的生死大权完全掌握在咱手中。怎么办?这个时候就应该谈谈Externalizable类了。

只要实现Externalizable这个类,并且复写

1
2
3
readExternal(ObjectInput in) throws IOException,CalssNotFoundException

writeExternal(ObjectOutput out) throws IOException,CalssNotFoundException

就可以了,在readExternal(ObjectInput in) throws IOException,CalssNotFoundException方法中,可以自行决定从in读取哪些对象数据。

writeExternal(ObjectOutput out) throws IOException,CalssNotFoundException方法中,可以自行决定将什么数据write到out去。

这俩个方法分别会在在ObjectOutputStream.writeObject(object);ObjectInputStream.readObject()自动执行。

参考地址

1、https://zhidao.baidu.com/question/688891250408618484.html
2、https://blog.csdn.net/morethinkmoretry/article/details/5929345
3、https://www.jianshu.com/p/edcf7bd2c085
4、https://blog.csdn.net/xiaocaidexuexibiji/article/details/22692097

SimpleDateFormat是Java提供的一个格式化和解析日期的工具类,日常开发中应该经常会用到,但是由于它是线程不安全的,多线程公用一个SimpleDateFormat实例对日期进行解析或者格式化会导致程序出错。

代码示例演示

写一段小Demo来模拟多线程下SimpleDateFormat做时间格式化的时候报错,代码如下:

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
package com.vernon.test.demo.jdk.text;
import java.text.ParseException;
import java.text.SimpleDateFormat;
/**
* Created with vernon-test
* Description:
* User:chenyuan
* Date:2019/3/20 Time:2:03 PM
*/
public class SimpleDateFormatCase {
//1、创建单例实例
static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) {
//2、创建多个线程,并启动
for (int i = 0; i < 10; ++i) {
Thread thread = new Thread(new Runnable() {
public void run() {
try {
//3、使用单例日期实例解析文本
System.out.println(sdf.parse("2017-12-13 15:17:27"));
} catch (ParseException e) {
e.printStackTrace();
}
}
});
//4、启动线程
thread.start();
}
}
}
控制台正常的情况: 运气好~
1
2
3
4
5
6
7
8
9
10
11
12
Connected to the target VM, address: '127.0.0.1:57434', transport: 'socket'
Wed Dec 13 15:17:27 CST 2017
Wed Dec 13 15:17:27 CST 2017
Wed Dec 13 15:17:27 CST 2017
Wed Dec 13 15:17:27 CST 2017
Wed Dec 13 15:17:27 CST 2017
Wed Dec 13 15:17:27 CST 2017
Wed Dec 13 15:17:27 CST 2017
Wed Dec 13 15:17:27 CST 2017
Wed Dec 13 15:17:27 CST 2017
Wed Dec 13 15:17:27 CST 2017
Disconnected from the target VM, address: '127.0.0.1:57434', transport: 'socket'
控制台非正常的情况 运气不好~
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
Connected to the target VM, address: '127.0.0.1:57756', transport: 'socket'
Exception in thread "Thread-2" Exception in thread "Thread-0" java.lang.NumberFormatException: multiple points
at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1890)
at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
at java.lang.Double.parseDouble(Double.java:538)
at java.text.DigitList.getDouble(DigitList.java:169)
at java.text.DecimalFormat.parse(DecimalFormat.java:2056)
at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
at java.text.DateFormat.parse(DateFormat.java:364)
at com.vernon.test.demo.jdk.text.SimpleDateFormatCase$1.run(SimpleDateFormatCase.java:23)
at java.lang.Thread.run(Thread.java:748)
java.lang.NumberFormatException: multiple points
at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1890)
at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
at java.lang.Double.parseDouble(Double.java:538)
at java.text.DigitList.getDouble(DigitList.java:169)
at java.text.DecimalFormat.parse(DecimalFormat.java:2056)
at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
at java.text.DateFormat.parse(DateFormat.java:364)
at com.vernon.test.demo.jdk.text.SimpleDateFormatCase$1.run(SimpleDateFormatCase.java:23)
at java.lang.Thread.run(Thread.java:748)
Wed Dec 13 15:17:27 CST 2017
Wed Dec 13 15:17:27 CST 2017
Wed Dec 13 15:17:27 CST 2017
Fri Dec 12 15:17:27 CST 2217
Thu Dec 13 15:17:27 CST 2012
Wed Dec 13 15:17:27 CST 2017
Wed Dec 13 15:17:27 CST 2017
Fri Jun 09 15:17:27 CST 5881628
Disconnected from the target VM, address: '127.0.0.1:57756', transport: 'socket'

通过IntelliJ IDEA的功能查看一下SimpleDateFormat的一个类关系图:

http://static.cyblogs.com/WX20200116-175032.png

可知每个SimpleDateFormat实例里面有一个Calendar对象,从后面会知道其实SimpleDateFormat之所以是线程不安全的就是因为Calendar是线程不安全的,后者之所以是线程不安全的是因为其中存放日期数据的变量都是线程不安全的,比如里面的fieldstime等。

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
public Date parse(String text, ParsePosition pos) {
//1、解析日期字符串放入CalendarBuilder的实例calb中
.....
Date parsedDate;
try {
//2、使用calb中解析好的日期数据设置calendar
parsedDate = calb.establish(calendar).getTime();
...
}
catch (IllegalArgumentException e) {
...
return null;
}
return parsedDate;
}

Calendar establish(Calendar cal) {
...
//3、重置日期对象cal的属性值
cal.clear();
//4、使用calb中中属性设置cal
...
//5、返回设置好的cal对象
return cal;
}

public final void clear() {
for (int i = 0; i < fields.length; ) {
stamp[i] = fields[i] = 0; // UNSET == 0
isSet[i++] = false;
}
areAllFieldsSet = areFieldsSet = false;
isTimeSet = false;
}
  • 1、解析日期字符串放入CalendarBuilder的实例calb中;

  • 2、使用calb中解析好的日期数据设置calendar

  • 3、重置日期对象cal的属性值;

  • 4、使用calb中中属性设置cal

  • 5、返回设置好的cal对象;

从上面步骤可知步骤3、4、5操作不是原子性操作,当多个线程调用parse方法时候比如线程A执行了步骤3、4也就是设置好了cal对象,在执行步骤5前线程B执行了步骤3清空了cal对象,由于多个线程使用的是一个cal对象,所以线程A执行步骤5返回的就可能是被线程B清空后的对象,当然也有可能线程B执行了步骤4被线程B修改后的cal对象,从而导致程序错误。

那么怎么解决呢?

第一种方式:

每次使用时候new一个SimpleDateFormat的实例,这样可以保证每个实例使用自己的Calendar实例,但是每次使用都需要new一个对象,并且使用后由于没有其它引用,就会需要被回收,开销会很大。

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
package com.vernon.test.demo.jdk.text;
import java.text.ParseException;
import java.text.SimpleDateFormat;

/**
* Created with vernon-test
* Description:
* User:chenyuan
* Date:2019/3/20
* Time:2:07 PM
*/
public class SimpleDateFormatCase2 {

public static void main(String[] args) {
for (int i = 0; i < 10; ++i) {
final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Thread thread = new Thread(new Runnable() {
public void run() {
try {
synchronized (sdf) {
System.out.println(sdf.parse("2020-01-16 15:17:27"));
}
} catch (ParseException e) {
e.printStackTrace();
}
}
});
thread.start();
}
}
}
第二种方式:

究其原因是因为多线程下步骤3、4、5三个步骤不是一个原子性操作,那么容易想到的是对其进行同步,让3、4、5成为原子操作,可以使用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
package com.vernon.test.demo.jdk.text;

import java.text.ParseException;
import java.text.SimpleDateFormat;
/**
* Created with vernon-test
* Description:
* User:chenyuan Date:2019/3/21 Time:10:32 AM
*/
public class SimpleDateFormatCase3 {

static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

public static void main(String[] args) {
for (int i = 0; i < 10; ++i) {
Thread thread = new Thread(new Runnable() {
public void run() {
try {
synchronized (sdf) {
System.out.println(sdf.parse("2020-01-16 15:17:27"));
}
} catch (ParseException e) {
e.printStackTrace();
}
}
});
thread.start();
}
}
}
第三种方式:

使用ThreadLocal,这样每个线程只需要使用一个SimpleDateFormat实例相比第一种方式大大节省了对象的创建销毁开销,并且不需要对多个线程直接进行同步,使用ThreadLocal方式。

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
package com.vernon.test.demo.jdk.text;

import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
/**
* Created with vernon-test
* Description:
* User:chenyuan
* Date:2019/3/21 Time:10:32 AM
*/
public class SimpleDateFormatCase4 {

static ThreadLocal<DateFormat> safeSdf = new ThreadLocal<DateFormat>(){
@Override
protected SimpleDateFormat initialValue(){
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
}
};

public static void main(String[] args) {
for (int i = 0; i < 10; ++i) {
Thread thread = new Thread(new Runnable() {
public void run() {
try {
System.out.println(safeSdf.get().parse("2020-01-16 15:17:27"));
} catch (ParseException e) {
e.printStackTrace();
}
}
});
thread.start();
}
}
}
第四种方式:

在JDK8中新增了DateTimeFormatter,由DateTimeFormatter的静态方法ofPattern()构建日期格式,LocalDateTimeLocalDate等一些表示日期或时间的类使用parseformat方法把日期和字符串做转换。使用新的API,整个转换过程都不需要考虑线程安全的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.vernon.test.demo.jdk.text;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
/**
* Created with vernon-test
* Description:
* User:chenyuan
* Date:2019/3/21 Time:10:32 AM
*/
public class SimpleDateFormatCase5 {
static DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) {
for (int i = 0; i < 10; ++i) {
Thread thread = new Thread(new Runnable() {
public void run() {
System.out.println(LocalDate.parse("2020-01-16 15:17:27", formatter));
}
});
thread.start();
}
}
}

总结

Java8发布,已有数年之久,但是发现很多人都还是坚持着用SimpleDateFormatDate进行时间操作。SimpleDateFormat这个类不是线程安全的,在使用的时候稍不注意,就会产生致命的问题。Date这个类,是可以重新设置时间的,这对于一些类内部的属性来说,是非常不安全的。

Map家族介绍

我们都知道HashMap是线程不安全的,但是HashMap的使用频率在所有Map中确实属于比较高的。因为它可以满足我们大多数的场景了。

看一眼Map家族的关系图:

http://static.cyblogs.com/WX20200117-161643@2x.png

Map是一个接口,我们常用的实现类有HashMapLinkedHashMapTreeMapHashTable

HashMap

HashMap根据key的·值来保存value,需要注意的是,HashMap不保证遍历的顺序和插入的顺序是一致的。HashMap允许有一条记录的keynull,但是对值是否为null不做要求。

HashTable

HashTable类是线程安全的,它使用synchronize来做线程安全,全局只有一把锁,在线程竞争比较激烈的情况下hashtable的效率是比较低下的。因为当一个线程访问hashtable的同步方法时,其他线程再次尝试访问的时候,会进入阻塞或者轮询状态,比如当线程1使用put进行元素添加的时候,线程2不但不能使用put来添加元素,而且不能使用get获取元素。所以,竞争会越来越激烈。相比之下,ConcurrentHashMap使用了分段锁技术来提高了并发度,不在同一段的数据互相不影响,多个线程对多个不同的段的操作是不会相互影响的。每个段使用一把锁。所以在需要线程安全的业务场景下,推荐使用ConcurrentHashMap,而HashTable不建议在新的代码中使用,如果需要线程安全,则使用ConcurrentHashMap,否则使用HashMap就足够了。

LinkedHashMap

LinkedHashMap属于HashMap的子类,与HashMap的区别在于LinkedHashMap保存了记录插入的顺序。

TreeMap

TreeMap实现了SortedMap接口,TreeMap有能力对插入的记录根据key排序,默认按照升序排序,也可以自定义比较强,在使用TreeMap的时候,key应当实现Comparable

HashMap的实现

Java7Java7在实现HashMap上有所区别,当然Java7的效率要更好一些,主要是Java7HashMapJava7的基础上增加了红黑树这种数据结构,使得在桶里面查找数据的复杂度从O(n)降到O(logn),当然还有一些其他的优化,比如resize的优化等。
介于Java7HashMap较为复杂,本文将基于Java7HashMap实现来说明,主要的实现部分还是一致的,Java7的实现上主要是做了一些优化,内容还是没有变化的,依然是线程不安全的。
HashMap的实现使用了一个数组,每个数组项里面有一个链表的方式来实现,因为HashMap使用keyhashCode来寻找存储位置,不同的key可能具有相同的hashCode,这时候就出现哈希冲突了,也叫做哈希碰撞,为了解决哈希冲突,有开放地址方法,以及链地址方法。HashMap的实现上选取了链地址方法,也就是将哈希值一样的entry保存在同一个数组项里面,可以把一个数组项当做一个桶,桶里面装的entrykeyhashCode是一样的。

http://static.cyblogs.com/WX20200117-160020@2x.png

上面的图片展示了我们的描述,其中有一个非常重要的数据结构Node<K,V>,这就是实际保存我们的key-value对的数据结构,下面是这个数据结构的主要内容:

1
2
3
4
final int hash;    
final K key;
V value;
Node<K,V> next;

一个Node就是一个链表节点,也就是我们插入的一条记录,明白了HashMap使用链地址方法来解决哈希冲突之后,我们就不难理解上面的数据结构,hash字段用来定位桶的索引位置,keyvalue就是我们的数据内容,需要注意的是,我们的keyfinal的,也就是不允许更改,这也好理解,因为HashMap使用keyhashCode来寻找桶的索引位置,一旦key被改变了,那么keyhashCode很可能就会改变了,所以随意改变key会使得我们丢失记录(无法找到记录)。next字段指向链表的下一个节点。

HashMap的初始桶的数量为16loadFact0.75,当桶里面的数据记录超过阈值的时候,HashMap将会进行扩容则操作,每次都会变为原来大小的2倍,直到设定的最大值之后就无法再resize了。

下面对HashMap的实现做简单的介绍,具体实现还得看代码,对于Java7中的HashMap实现,还需要能理解红黑树这种数据结构。

1、根据keyhashCode来决定应该将该记录放在哪个桶里面,无论是插入、查找还是删除,这都是第一步,计算桶的位置。因为HashMaplength总是2的n次幂,所以可以使用下面的方法来做模运算:

1
h & (length-1)

hkeyhashCode值,计算好hashCode之后,使用上面的方法来对桶的数量取模,将这个数据记录落到某一个桶里面。当然取模是Java7中的做法,Java7进行了优化,做得更加巧妙,因为我们的length总是2的n次幂,所以在一次resize之后,当前位置的记录要么保持当前位置不变,要么就向前移动length就可以了。所以Java7中的HashMapresize不需要重新计算hashCode。我们可以通过观察java7中的计算方法来抽象出算法,然后进行优化,具体的细节看代码就可以了。

2、HashMapput方法

http://static.cyblogs.com/WX20200117-160735@2x.png

上图展示了Java7put方法的处理逻辑,比Java7多了红黑树部分,以及在一些细节上的优化,put逻辑和Java7中是一致的。

3、resize机制

HashMap的扩容机制就是重新申请一个容量是当前的2倍的桶数组,然后将原先的记录逐个重新映射到新的桶里面,然后将原先的桶逐个置为null使得引用失效。后面会讲到,HashMap之所以线程不安全,就是resize这里出的问题。

为什么HashMap线程不安全?

上面说到,HashMap会进行resize操作,在resize操作的时候会造成线程不安全。下面将举两个可能出现线程不安全的地方。

1、put的时候导致的多线程数据不一致。
这个问题比较好想象,比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来的桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。

2、另外一个比较明显的线程不安全的问题是HashMap的get操作可能因为resize而引起死循环(cpu100%),具体分析如下:

下面的代码是resize的核心内容:

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

这个方法的功能是将原来的记录重新计算在新桶的位置,然后迁移过去。

http://static.cyblogs.com/WX20200117-160831@2x.png

多线程HashMap的resize:我们假设有两个线程同时需要执行resize操作,我们原来的桶数量为2,记录数为3,需要resize桶到4,原来的记录分别为:[3,A][7,B][5,C],在原来的map里面,我们发现这三个entry都落到了第二个桶里面。
假设线程thread1执行到了transfer方法的Entry next = e.next这一句,然后时间片用完了,此时的e = [3,A], next = [7,B]。线程thread2被调度执行并且顺利完成了resize操作,需要注意的是,此时的[7,B]next[3,A]。此时线程thread1重新被调度运行,此时的thread1持有的引用是已经被thread2 resize之后的结果。线程thread1首先将[3,A]迁移到新的数组上,然后再处理[7,B],而[7,B]被链接到了[3,A]的后面,处理完[7,B]之后,就需要处理[7,B]next了啊,而通过thread2resize之后,[7,B]next变为了[3,A],此时,[3,A][7,B]形成了环形链表,在get的时候,如果getkey的桶索引和[3,A][7,B]一样,那么就会陷入死循环。

如果在取链表的时候从头开始取(现在是从尾部开始取)的话,则可以保证节点之间的顺序,那样就不存在这样的问题了。

综合上面两点,可以说明HashMap是线程不安全的。

参考地址

前言

要想深入了解Java并发编程,就要先理解好Java内存模型,而要理解Java内存模型又不得不从硬件、计算机内存模型说起,本文从计算机内存模型产生的原因、解决的问题谈起,然后再对Java模型进行介绍,最后对计算机内存模型和Java内存模型进行总结,希望大家看完本文之后有所收获!

CPU工作过程及出现的问题

CPU执行过程

大家都知道,计算机在执行程序时,每条指令都是在CPU中执行的,而执行的时候,又免不了要和数据打交道,而计算机上面的临时数据,是储存在主存中的。

计算机内存包括高速缓存主存

我们知道CPU执行指令的速度比从主存读取数据和向主存写入数据快很多,所以为了高效利用CPU,CPU增加了**高速缓存(cache)**来匹配CPU的执行速度,最终程序的执行过程如下

  1. 首先会将数据从主存中复制一份到CPU的高速缓存中
  2. 当CPU执行计算的时候就可以直接从高速缓存中读取数据和写入数据
  3. 当运算结束后,再将高速缓存的数据更新到主存中
缓存一致性问题

上面的执行过程在单线程情况下并没有问题,但是在多线程情况下就会出现问题,因为CPU如果含有多个核心,则每个核心都有自己独占高速缓存,如果出现多个线程同时执行同一个操作,那么结果是无法预知。例如2个线程同时执行i++,假设i的初始值是0,那么我们希望2个线程执行完成之后i的值变为2,但是事实会是这样吗?
http://static.cyblogs.com/WX20200212-224624@2x.png

可能出现的情况有:

  1. 线程1先将i=0从主存中读取到线程1的高速缓存中,然后CPU完成运算,再将i=1写入到主存中,然后线程2开始从主存中读取i=1到线程2的高速缓存中,然后CPU完成运算,再将i=2写入到主存中,那么i=2即为我们想要的结果。
  2. 线程1将i=0从主存中读取到线程1的高速缓存中的同时线程2也从主存中读取i=0到线程2的高速缓存中,然后线程1和线程2完成运算后,也都将i=1写入到主存中,那么结果i=1,结果就不是我们想要的了。出现这个情况,我们称为缓存不一致问题

那么如何解决CPU出现的缓存不一致问题呢?通常使用的解决方法有2种:

  1. 通过给总线加锁
  2. 使用缓存一致性协议

http://static.cyblogs.com/WX20200212-224440@2x.png

1种方法虽然也达到了目的,但是在总线被锁住的期间,其他的CPU也无法访问主存,效率很低,所以就出现了缓存一致性协议即第2种方法,其中最出名的就是Intel的MESI协议,MESI协议保证每个CPU高速缓存中的变量都是一致的。它的核心思想是,当CPU写数据时候,如果发现操作的变量是共享变量(即其他CPU上也存在该变量),就会发出信号通知其他CPU将它高速缓存中缓存这个变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己高速缓存中缓存该变量的缓存行为无效状态,那么它就会从主存中重新读取
http://static.cyblogs.com/WX20200212-224337@2x.png

处理器重排序问题

在多线程场景下,CPU除了会出现缓存一致性问题,还会出现因为处理器重排序处理器(CPU)为了提高效率可能会对输入的代码进行乱序执行,而造成多线程的情况下出现问题。
例如:

1
2
3
4
5
6
7
8
9
//线程1:
context = loadContext(); //语句1
inited = true; //语句2

//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);

线程1由于处理器重排序,先执行性了语句2,那么此时线程2会认为context已经初始化完成,那么跳出循环,去执行doSomethingwithconfig(context)方法,实际上此时context并未初始化(即线程1的语句1还未执行),而导致程序出错。

什么是计算机内存模型

上面提到的缓存一致性问题处理器重排序问题都是在多线程情况下CPU可能出现的问题,那我们应该怎么处理这些问题?实际上这些问题并不需要我们考虑,这些问题CPU都会处理好,而CPU处理这些问题的时候是按照特定的操作规范,对特定的主存进行访问或告诉CPU高速缓存怎么访问主存,保证了多线程场景下的原子性、可见性、有序性,这个操作规范就称为计算机内存模型

可见性即当一个变量修改后,这个变量会马上更新到主存中,其他线程会收到通知这个变量修改过了,使用这个变量的时候重新去主存获取

什么是Java内存模型

从前面的介绍了解到计算机内存模型是一种解决多线程场景下的一个主存操作规范,既然是规范,那么不同的编程语言都可以遵循这种操作规范,在多线程场景下访问主存保证原子性、可见性、有序性。
Java内存模型(Java Memory Model,JMM)即是Java语言对这个操作规范的遵循,JMM规定了所有的变量都存储在主存中,每个线程都有自己的工作区,线程将使用到的变量从主存中复制一份到自己的工作区,线程对变量的所有操作(读取、赋值等)都必须在工作区,不同的线程也无法直接访问对方工作区,线程之间的消息传递都需要通过主存来完成。可以把这里主存类比成计算机内存模型中的主存,工作区类比成计算机内存模型中的高速缓存

http://static.cyblogs.com/WX20200212-224252@2x.png

而我们知道JMM其实是工作主存中的,Java内存模型中的工作区也是主存中的一部分,所以可以这样说Java内存模型解决的是内存一致性问题(主存和主存)而计算机内存模型解决的是缓存一致性问题(CPU高速缓存和主存),这两个模型类似,但是作用域不一样,Java内存模型保证的是主存和主存之间的原子性、可见性、有序性,而计算机内存模型保证的是CPU高速缓存和主存之间的原子性、可见性、有序性。

http://static.cyblogs.com/WX20200212-224147@2x.png

总结

本文很多观点都是按照笔者自己的理解然后总结出来的,若有偏颇,欢迎指正!

参考

如何理解Linux的上下文切换

  • Linux 是一个多任务操作系统,它支持同时运行的任务数量远大于 CPU 个数。其实这些任务没有真正的同时运行,是因为系统在很短的时间内,将 CPU 轮流分配给它们,造成多任务同时运行的错觉。
  • 而在每个任务运行前,CPU 都需要知道任务从哪里加载、从哪里开始运行,需要系统事先设置好 CPU 寄存器程序计数器CPU 寄存器是 CPU 内置的容量小、速度极快的内存。而程序计数器则是用来存储 CPU 正在执行的指令位置、或即将执行的下一条指令位置。它们都是 CPU 在运行任务前必须依赖的环境,也被叫做 CPU 上下文
  • 上下文切换,就是先把前一个任务的 CPU 上下文保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳到程序计数器所指的新位置,运行新任务。而这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。

根据任务的不同,CPU 的上下文切换可以分为几个不同的场景,也就是:进程上下文切换、线程上下文切换、中断上下文切换。

进程上下文切换

1、用户空间与内核空间

Linux 按照特权等级,把进程的运行空间分为内核空间和用户空间,分别对应着 CPU 特权等级的 Ring 0Ring 3

  • 内核空间(Ring 0)具有最高权限,可以直接访问所有资源。
  • 用户空间(Ring 3)只能访问受限资源,不能直接访问内存等硬件设备,必须通过系统调用陷入内核中才能访问这些特权资源。
  • 进程既可以在用户空间运行,又可以在内核空间运行。在用户空间运行时被称为进程的用户态,而陷入内核空间的时候,被称为进程的内核态。

2、系统调用

从用户态到内核态的转变,需要通过系统调用来完成。比如查看文件时,需要执行多次系统调用:open、read、write、close等。系统调用的过程如下:

  • 首先,把 CPU 寄存器里原来用户态的指令位置保存起来
  • 为了执行内核代码,CPU 寄存器需要更新为内核态指令的新位置,最后跳转到内核态运行内核任务。
  • 系统调用结束后,CPU 寄存器需要恢复原来保存的用户态,然后再切换到用户空间,继续运行进程
  • 所以,一次系统调用的过程,其实是发生了两次 CPU 上下文切换。

但系统调用的过程中并不会涉及虚拟内存等进程用户态的资源,也不会切换进程,这和平时说的进程上下文切换是不一样的:

  • 进程上下文切换,是指从一个进程切换到另一个进程运行
  • 系统调用过程中一直是同一个进程在运行

因此,系统调用的过程通常称为特权模式切换,而不是上下文切换。

3、进程上下文切换

进程是由内核来管理和调度的,进程的切换只能发生在内核态,因此进程的上下文不仅包括了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的状态。

因此进程的上下文切换就比系统调用时多了一步:在保存当前进程的内核状态和 CPU 寄存器之前,需先把该进程的虚拟内存、栈等保存下来;而加载了下一进程的内核态后,还需要刷新进程的虚拟内存和用户栈。

保存上下文和恢复上下文的过程并不是免费的,需要内核在 CPU 上运行才能完成。据测试,每次上下文切换都需要几十纳秒到数微妙的 CPU 时间。特别是在进程上下文切换次数较多的情况下,很容易导致 CPU 将大量时间消耗在寄存器、内核栈、虚拟内存等资源的保存和恢复上,从而大大缩短了真正运行进程的时间。

Linux 通过 TLB 来管理虚拟内存到物理内存的映射关系。当虚拟内存更新后,TLB 也需要刷新,内存的访问也会随之变慢。特别是多处理器系统上,缓存是被多个处理器共享的,刷新缓存不仅会影响当前处理器的进程,还会影响共享缓存的其它处理器的进程。

4、进程上下文何时切换

Linux 为每个 CPU 维护了一个就绪队列,将活跃进程按照优先级和等待 CPU 的时间排序,然后选择最需要 CPU 的进程,也就是优先级最高和等待 CPU 时间最长的进程来运行。那么,进程在什么时候才会被调度到 CPU 上运行呢?

  • 进程执行完终止了,它之前使用的 CPU 会释放出来,这时再从就绪队列中拿一个新的进程来运行
  • 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片被轮流分配给各个进程。当某个进程时间片耗尽了就会被系统挂起,切换到其它等待 CPU 的进程运行。
  • 进程在系统资源不足时,要等待资源满足后才可以运行,这时进程也会被挂起,并由系统调度其它进程运行。
  • 当进程通过睡眠函数 sleep 主动挂起时,也会重新调度。
  • 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行。
  • 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序。

线程上下文切换

线程与进程最大的区别在于,线程是操作系统调度的最小单位,而进程是操作系统分配资源的最小单位。所谓内核调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。对于线程和进程我们可以这么理解:

  • 当进程只有一个线程时,可以认为进程就等于线程
  • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源。这些资源在上下文切换时是不需要修改的。
  • 另外线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也时需要保存的。

其实线程的上下文切换可以分为两种情况:

  • 前后两个线程属于不同进程。此时因为资源不共享,所以切换过程就跟进程上下文切换是一样的。
  • 前后两个线程属于同一个进程。此时虚拟内存是共享的,上下文切换时,虚拟内存这些资源保持不动,只需要切换线程的私有数、寄存器等不共享的数据。

可以发现同进程内的线程切换,要比多进程间的切换消耗更少的资源,这也正是多线程代替多进程的一个优势。

中断上下文切换

为了快速响应硬件的事件,中断处理会打断进程的正常调度和执行,转而调用中断处理程序,响应设备事件。而在打断其它进程时,就需要将进程当前的状态保存下来,这样在中断结束后,进程仍然可以从原来的状态恢复运行。

  • 跟进程上下文不同,中断上下文切换并不涉及到进程的用户态。所以即便中断过程打断了一个正在用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户态资源。中断上下文其实只包括内核态中断服务程序执行所必需的状态,包括 CPU 寄存器、内核堆栈、硬件中断参数等。
  • 对同一个 CPU 来说,中断处理比进程拥有更高的优先级,由于中断会打断正常进程的调度和执行,所以大部分中断处理程序都短小精悍,以便尽可能快的执行结束。
  • 跟进程上下文切换一样,中断上下文切换也需要消耗 CPU,当发现中断次数过多时,就需要注意去排查它是否会给你的系统带来严重的性能问题。

概念小结

总结一下,不管是哪种场景导致的上下文切换,你都应该知道:

  • CPU 上下文切换是保证 Linux 系统正常工作的核心功能之一,一般情况下我们无需特别关注。
  • 过多的上下文切换,会把 CPU 时间消耗在寄存器、内核栈、虚拟内存等数据的保存和恢复上,从而缩短进程真正运行的时间,导致系统的整体性能大幅下降。

如何查看系统的上下文切换

我们可以通过 vmstat 工具来查看系统的上下文切换情况。vmstat 主要用来分析系统内存使用情况,也常用来分析 CPU 上下文切换和中断的次数。

1
2
3
4
5
# 每隔 5 秒输出 1 组数据
$ vmstat 5
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 0 0 7005360 91564 818900 0 0 0 0 25 33 0 0 100 0 0

我们需要重点关注下列四项内容:

  • cs(context switch) 是每秒上下文切换的次数。
  • in(interrupt) 是每秒中断的次数。
  • r(Running or Runnable) 是就绪队列的长度,也就是正在运行和等待 CPU 的进程数。
  • b(Blocked) 是处于不可中断睡眠状态的进程数。

想要查看每个进程的详细情况,需要使用 pidstat,给它加上 -w 选项,就可以查看每个进程上下文切换的情况。

1
2
3
4
5
6
# 每隔 5 秒输出 1 组数据
$ pidstat -w 5
Linux 4.15.0 (ubuntu) 09/23/18 _x86_64_ (2 CPU)
08:18:26 UID PID cswch/s nvcswch/s Command
08:18:31 0 1 0.20 0.00 systemd
08:18:31 0 8 5.40 0.00 rcu_sched

上述结果有两列是我们重点关注的对象,一个是 cswch,表示每秒自愿上下文切换的次数;另一个是 nvcswch,表示每秒非自愿上下文切换的次数。

  • 自愿上下文切换,是指进程无法获取所需资源,导致的上下文切换。比如,IO、内存等系统资源不足时,就会发生自愿上下文切换。
  • 非资源上下文切换,则是指进程由于时间片已到等原因,被系统强制调度,进而发生的上下文切换。比如说,大量进程都在抢占 CPU 时,就容易发生非自愿上下文切换。

案例分析

准备环境

sysbench 是一个多线程的基准测试工具,一般用来评估不同系统参数下的数据库负载情况,本次案例把它当作一个异常进程来看,作用是模拟上下文切换过多的问题。

1
2
# 预先安装 sysbench
$ yum install sysbench -y

操作和分析

首先在第一个终端里运行 sysbench,模拟系统多线程调度的瓶颈:

1
2
# 以 10 个线程运行 5 分钟的基准测试,模拟多线程切换的问题
$ sysbench --threads=10 --max-time=300 threads run

接着在第二个终端运行 vmstat,观察上下文切换情况:

1
2
3
4
5
6
# 每隔 1 秒输出 1 组数据(需要 Ctrl+C 才结束)
$ vmstat 1
procs --------memory-------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
6 0 0 6487428 118240 1292772 0 0 0 0 9019 1398830 16 84 0 0 0
8 0 0 6487428 118240 1292772 0 0 0 0 10191 1392312 16 84 0 0 0

可以发现,cs 列的上下文切换次数从之前的 35 上升到了 139 万,观察其他几个指标:

  • r 列:就绪队列长度为 8,远大于 CPU 个数,所以会有大量的 CPU 竞争
  • us 和 sys 列:这两列加一起上升到 100%,sys 列高达 84%,说明 CPU 主要是被内核占用了。
  • in 列:中断次数为 1 万左右,说明中断也是个潜在的问题。

综合分析,由于系统的就绪队列过长,也就是正在运行和等待 CPU 的进程数过多,导致了大量的上下文切换,而上下文切换又导致了系统 CPU 的占用率升高。

我们可以使用 pidstat 继续分析到底是哪个进程导致了这些问题?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 每隔 1 秒输出 1 组数据(需要 Ctrl+C 才结束)
# -w 参数表示输出进程切换指标,而 -u 参数则表示输出 CPU 使用指标
$ pidstat -w -u 1
08:06:33 UID PID %usr %system %guest %wait %CPU CPU Command
08:06:34 0 10488 30.00 100.00 0.00 0.00 100.00 0 sysbench
08:06:34 0 26326 0.00 1.00 0.00 0.00 1.00 0 kworker/u4:2

08:06:33 UID PID cswch/s nvcswch/s Command
08:06:34 0 8 11.00 0.00 rcu_sched
08:06:34 0 16 1.00 0.00 ksoftirqd/1
08:06:34 0 471 1.00 0.00 hv_balloon
08:06:34 0 1230 1.00 0.00 iscsid
08:06:34 0 4089 1.00 0.00 kworker/1:5
08:06:34 0 4333 1.00 0.00 kworker/0:3
08:06:34 0 10499 1.00 224.00 pidstat
08:06:34 0 26326 236.00 0.00 kworker/u4:2
08:06:34 1000 26784 223.00 0.00 sshd

可以发现,CPU 使用率的升高是 sysbench 导致的,但上下文切换则来自其他进程,包括非自愿上下文切换频率最高的 pidstat,以及自愿上下文切换频率最高的内核线程 kworkersshd

默认 pidstat 显示进程的指标数据,加上 -t 参数后,才会输出线程的指标

1
2
3
4
5
6
7
8
9
10
11
# 每隔 1 秒输出一组数据(需要 Ctrl+C 才结束)
# -wt 参数表示输出线程的上下文切换指标
$ pidstat -wt 1
08:14:05 UID TGID TID cswch/s nvcswch/s Command
...
08:14:05 0 10551 - 6.00 0.00 sysbench
08:14:05 0 - 10551 6.00 0.00 |__sysbench
08:14:05 0 - 10552 18911.00 103740.00 |__sysbench
08:14:05 0 - 10553 18915.00 100955.00 |__sysbench
08:14:05 0 - 10554 18827.00 103954.00 |__sysbench
...

虽然 sysbench 进程的上下文切换次数不多,但它的子线程的上下文切换次数非常多,可以判定上下文切换罪魁祸首的是 sysbench 进程。还没完,记得我们通过 vmstat 看到的中断次数到了 1 万,到底是什么类型的中断上升了呢?

我们可以通过 /proc/interrupts 来读取中断的使用情况,通过运行下面的命令:

1
2
3
4
5
6
# -d 参数表示高亮显示变化的区域
$ watch -d cat /proc/interrupts
CPU0 CPU1
...
RES: 2450431 5279697 Rescheduling interrupts
...

可以发现,变化速度最快的是重调度中断(RES),表示唤醒空闲状态的 CPU 来调度新的任务运行。这是多处理器系统(SMP)中,调度器用来分散任务队列到不同 CPU 的机制,通常也被称为处理器间中断。根本原因还是因为过多任务的调度问题,跟前边分析结果是一致的。

每秒上下文切换多少次算正常

这个数值其实取决于系统本身的 CPU 性能。如果系统的上下文切换次数比较稳定,从数百到一万以内,都应该算是正常的。如果当上下文切换次数超过一万次,或者切换次数出现数量级增长时,很可能已经出现了性能问题。

这时,你还需要根据上下文切换的类型,再做具体分析,比方说:

  • 自愿上下文切换变多了,说明进程都在等待资源,有可能发生了 IO 等其他问题
  • 非自愿上下文切换变多了,说明进程都在被强制调度,也就是都在争抢 CPU,说明 CPU 的确成了瓶颈。
  • 中断次数变多了,说明 CPU 被中断处理程序占用,还需要通过查看 /proc/interrupts 文件来分析具体的中断类型。

参考地址

前言

上一篇文章介绍了多线程的概念及synchronized的使用方法《synchronized的使用(一)》,但是仅仅会用还是不够的,只有了解其底层实现才能在开发过程中运筹帷幄,所以本篇探讨synchronized的实现原理及锁升级(膨胀)的过程。

synchronized实现原理

synchronized是依赖于JVM来实现同步的,在同步方法和代码块的原理有点区别。

同步代码块

我们在代码块加上synchronized关键字

1
2
3
4
5
public void synSay() {
synchronized (object) {
System.out.println("synSay----" + Thread.currentThread().getName());
}
}

编译之后,我们利用反编译命令javap -v xxx.class查看对应的字节码,这里为了减少篇幅,我就只粘贴对应的方法的字节码。

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
public void synSay();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=3, args_size=1
0: aload_0
1: getfield #2 // Field object:Ljava/lang/String;
4: dup
5: astore_1
6: monitorenter
7: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
10: new #4 // class java/lang/StringBuilder
13: dup
14: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
17: ldc #6 // String synSay----
19: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
22: invokestatic #8 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
25: invokevirtual #9 // Method java/lang/Thread.getName:()Ljava/lang/String;
28: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
31: invokevirtual #10 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
34: invokevirtual #11 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
37: aload_1
38: monitorexit
39: goto 47
42: astore_2
43: aload_1
44: monitorexit
45: aload_2
46: athrow
47: return
Exception table:
from to target type
7 39 42 any
42 45 42 any
LineNumberTable:
line 21: 0
line 22: 7
line 23: 37
line 24: 47
LocalVariableTable:
Start Length Slot Name Signature
0 48 0 this Lcn/T1;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 42
locals = [ class cn/T1, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4

可以发现synchronized同步代码块是通过加monitorentermonitorexit指令实现的。
每个对象都有个**监视器锁(monitor) **,当monitor被占用的时候就代表对象处于锁定状态,而monitorenter指令的作用就是获取monitor的所有权,monitorexit的作用是释放monitor的所有权,这两者的工作流程如下:
monitorenter

  1. 如果monitor的进入数为0,则线程进入到monitor,然后将进入数设置为1,该线程称为monitor的所有者。
  2. 如果是线程已经拥有此monitor(即monitor进入数不为0),然后该线程又重新进入monitor,则将monitor的进入数+1,这个即为锁的重入
  3. 如果其他线程已经占用了monitor,则该线程进入到阻塞状态,知道monitor的进入数为0,该线程再去重新尝试获取monitor的所有权

monitorexit:执行该指令的线程必须是monitor的所有者,指令执行时,monitor进入数-1,如果-1后进入数为0,那么线程退出monitor,不再是这个monitor的所有者。这个时候其它阻塞的线程可以尝试获取monitor的所有权。

同步方法

在方法上加上synchronized关键字

1
2
3
synchronized public void synSay() {
System.out.println("synSay----" + Thread.currentThread().getName());
}

编译之后,我们利用反编译命令javap -v xxx.class查看对应的字节码,这里为了减少篇幅,我就只粘贴对应的方法的字节码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public synchronized void synSay();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: new #3 // class java/lang/StringBuilder
6: dup
7: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
10: ldc #5 // String synSay----
12: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
15: invokestatic #7 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
18: invokevirtual #8 // Method java/lang/Thread.getName:()Ljava/lang/String;
21: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
24: invokevirtual #9 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
27: invokevirtual #10 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
30: return
LineNumberTable:
line 20: 0
line 21: 30
LocalVariableTable:
Start Length Slot Name Signature
0 31 0 this Lcn/T1;

从字节码上看,加有synchronized关键字的方法,常量池中比普通的方法多了个ACC_SYNCHRONIZED标识,JVM就是根据这个标识来实现方法的同步。
当调用方法的时候,调用指令会检查方法是否有ACC_SYNCHRONIZED标识,有的话线程需要先获取monitor,获取成功才能继续执行方法,方法执行完毕之后,线程再释放monitor,同一个monitor同一时刻只能被一个线程拥有。

两种同步方式区别

synchronized同步代码块的时候通过加入字节码monitorentermonitorexit指令来实现monitor的获取和释放,也就是需要JVM通过字节码显式的去获取和释放monitor实现同步,而synchronized同步方法的时候,没有使用这两个指令,而是检查方法的ACC_SYNCHRONIZED标志是否被设置,如果设置了则线程需要先去获取monitor,执行完毕了线程再释放monitor,也就是不需要JVM去显式的实现。
这两个同步方式实际都是通过获取monitor和释放monitor来实现同步的,而monitor的实现依赖于底层操作系统的mutex互斥原语,而操作系统实现线程之间的切换的时候需要从用户态转到内核态,这个转成过程开销比较大。
线程获取、释放monitor的过程如下:

http://static.cyblogs.com/WX20200201-131434@2x.png

线程尝试获取monitor的所有权,如果获取失败说明monitor被其他线程占用,则将线程加入到的同步队列中,等待其他线程释放monitor当其他线程释放monitor后,有可能刚好有线程来获取monitor的所有权,那么系统会将monitor的所有权给这个线程,而不会去唤醒同步队列的第一个节点去获取,所以synchronized是非公平锁。如果线程获取monitor成功则进入到monitor中,并且将其进入数+1

关于什么是公平锁、非公平锁可以参考一下美团技术团队写的《不可不说的Java“锁”事》

到这里我们也清楚了synchronized的语义底层是通过一个monitor的对象完成,其实waitnotiyfnotifyAll等方法也是依赖于monitor对象来完成的,这也就是为什么需要在同步方法或者同步代码块中调用的原因(需要先获取对象的锁,才能执行),否则会抛出java.lang.IllegalMonitorStateException的异常

Java对象的组成

我们知道了线程要访问同步方法、代码块的时候,首先需要取得锁,在退出或者抛出异常的时候又必须释放锁,那么锁到底是什么?又储存在哪里?
为了解开这个疑问,我们需要进入Java虚拟机(JVM) 的世界。在HotSpot虚拟机中,Java对象在内存中储存的布局可以分为3块区域:对象头实例数据对齐填充synchronized使用的锁对象储存在对象头中

http://static.cyblogs.com/WX20200201-131531@2x.png

http://static.cyblogs.com/WX20200201-131609@2x.png

对象头

对象头的数据长度在32位和64位(未开启压缩指针)的虚拟机中分别为32bit64bit。对象头由以下三个部分组成:

  • Mark Word:记录了对象和锁的有关信息,储存对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁标志位、线程持有的锁、偏向线程ID、偏向时间戳、对象分代年龄等。注意这个Mark Word结构并不是固定的,它会随着锁状态标志的变化而变化,而且里面的数据也会随着锁状态标志的变化而变化,这样做的目的是为了节省空间
  • 类型指针:指向对象的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  • 数组长度:这个属性只有数组对象才有,储存着数组对象的长度。

32位虚拟机下,Mark Word的结构和数据可能为以下5种中的一种。

http://static.cyblogs.com/WX20200201-131652@2x.png

64位虚拟机下,Mark Word的结构和数据可能为以下2种中的一种。

http://static.cyblogs.com/WX20200201-131726@2x.png

这里重点注意是否偏向锁锁标志位,这两个标识和synchronized的锁膨胀息息相关。

实例数据

储存着对象的实际数据,也就是我们在程序中定义的各种类型的字段内容。

对齐填充

HotSpot虚拟机的对齐方式为8字节对齐,即一个对象必须为8字节的整数倍,如果不是,则通过这个对齐填充来占位填充。

synchronized锁膨胀过程

上文介绍的 “synchronized实现原理” 实际是synchronized实现重量级锁的原理,那么上文频繁提到monitor对象和对象又存在什么关系呢,或者说monitor对象储存在对象的哪个地方呢?
在对象的对象头中,当锁的状态为重量级锁的时候,它的指针即指向monitor对象,如图:

http://static.cyblogs.com/WX20200201-131856@2x.png

http://static.cyblogs.com/WX20200201-131932@2x.png

那锁的状态为其它状态的时候是不是就没用上monitor对象?答案:是的。
这也是JVMsynchronized的优化,我们知道重量级锁的实现是基于底层操作系统的mutex互斥原语的,这个开销是很大的。所以JVMsynchronized做了优化,JVM先利用对象头实现锁的功能,如果线程的竞争过大则会将锁升级(膨胀)为重量级锁,也就是使用monitor对象。当然JVM对锁的优化不仅仅只有这个,还有引入适应性自旋、锁消除、锁粗化、轻量级锁、偏向锁等。

那么锁的是怎么进行膨胀的或者依据什么来膨胀,这也就是本篇需要介绍的重点,首先我们需要了解几个概念。

锁的优化
自旋锁和自适应性自旋锁

自旋:当有个线程A去请求某个锁的时候,这个锁正在被其它线程占用,但是线程A并不会马上进入阻塞状态,而是循环请求锁(自旋)。这样做的目的是因为很多时候持有锁的线程会很快释放锁的,线程A可以尝试一直请求锁,没必要被挂起放弃CPU时间片,因为线程被挂起然后到唤醒这个过程开销很大,当然如果线程A自旋指定的时间还没有获得锁,仍然会被挂起。

自适应性自旋:自适应性自旋是自旋的升级、优化,自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态决定。例如线程如果自旋成功了,那么下次自旋的次数会增多,因为JVM认为既然上次成功了,那么这次自旋也很有可能成功,那么它会允许自旋的次数更多。反之,如果对于某个锁,自旋很少成功,那么在以后获取这个锁的时候,自旋的次数会变少甚至忽略,避免浪费处理器资源。有了自适应性自旋,随着程序运行和性能监控信息的不断完善,JVM对程序锁的状况预测就会变得越来越准确,JVM也就变得越来越聪明。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除

锁粗化

在使用锁的时候,需要让同步块的作用范围尽可能小,这样做的目的是为了使需要同步的操作数量尽可能小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁

轻量级锁

所谓轻量级锁是相对于使用底层操作系统mutex互斥原语实现同步的重量级锁而言的,因为轻量级锁同步的实现是基于对象头的Mark Word。那么轻量级锁是怎么使用对象头来实现同步的呢,我们看看具体实现过程。

获取锁过程

  1. 在线程进入同步方法、同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为”01”状态,是否为偏向锁为”0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Recored)的空间,用于储存锁对象目前的Mark Word的拷贝(官方把这份拷贝加了个Displaced前缀,即Displaced Mark Word)。

http://static.cyblogs.com/WX20200201-132045@2x.png

  1. 将对象头的Mark Word拷贝到线程的锁记录(Lock Recored)中。
  2. 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新成功了,则执行步骤4,否则执行步骤5
  3. 更新成功,这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位将转变为”00”,即表示此对象处于轻量级锁的状态。

http://static.cyblogs.com/WX20200201-132119@2x.png

  1. 更新失败,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行,否则说明这个锁对象已经被其其它线程抢占了。进行自旋执行步骤3,如果自旋结束仍然没有获得锁,轻量级锁就需要膨胀为重量级锁,锁标志位状态值变为”10”,Mark Word中储存就是指向monitor对象的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。

http://static.cyblogs.com/WX20200201-132152@2x.png

释放锁的过程

  1. 使用CAS操作将对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来(依据Mark Word中锁记录指针是否还指向本线程的锁记录),如果替换成功,则执行步骤2,否则执行步骤3
  2. 如果替换成功,整个同步过程就完成了,恢复到无锁的状态(01)。
  3. 如果替换失败,说明有其他线程尝试获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。
偏向锁

偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作区消除同步使用的互斥量,那么偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不用做了。偏向锁默认是开启的,也可以关闭
偏向锁”偏”,就是”偏心”的”偏”,它的意思是这个锁会偏向于第一个获得它的程序,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

获取锁的过程

  1. 检查Mark Word是否为可偏向锁的状态,即是否偏向锁即为1即表示支持可偏向锁,否则为0表示不支持可偏向锁。
  2. 如果是可偏向锁,则检查Mark Word储存的线程ID是否为当前线程ID,如果是则执行同步块,否则执行步骤3
  3. 如果检查到Mark WordID不是本线程的ID,则通过CAS操作去修改线程ID修改成本线程的ID,如果修改成功则执行同步代码块,否则执行步骤4
  4. 当拥有该锁的线程到达安全点之后,挂起这个线程,升级为轻量级锁。

锁释放的过程

  1. 有其他线程来获取这个锁,偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。
  2. 等待全局安全点(在这个是时间点上没有字节码正在执行)。
  3. 暂停拥有偏向锁的线程,检查持有偏向锁的线程是否活着,如果不处于活动状态,则将对象头设置为无锁状态,否则设置为被锁定状态。如果锁对象处于无锁状态,则恢复到无锁状态(01),以允许其他线程竞争,如果锁对象处于锁定状态,则挂起持有偏向锁的线程,并将对象头Mark Word的锁记录指针改成当前线程的锁记录,锁升级为轻量级锁状态(00)

http://static.cyblogs.com/WX20200201-132302@2x.png

锁的转换过程

锁主要存在4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争的情况逐渐升级,这几个锁只有重量级锁是需要使用操作系统底层mutex互斥原语来实现,其他的锁都是使用对象头来实现的。需要注意锁可以升级,但是不可以降级。

http://static.cyblogs.com/WX20200201-132345@2x.png

这里盗个图,这个图总结的挺好的!(图被压缩过了 看不清,可以打开这个地址查看高清图>>高清图<<)

http://static.cyblogs.com/WX20200201-132428@2x.png

三种锁的优缺点比较

http://static.cyblogs.com/WX20200201-132500@2x.png

参考

0%