作者:汤圆
集体博客:javalover.cc
前言
官人们好啊,我是汤圆,明天给大家带来的是《Java 并发 - 同步容器篇》,心愿有所帮忙,谢谢
文章如果有问题,欢送大家批评指正,在此谢过啦
简介
同步容器次要分两类,一种是 Vector 这样的一般类,一种是通过 Collections 的工厂办法创立的外部类
尽管很多人都对同步容器的性能低有偏见,但它也不是一无是处,在这里咱们插播一条阿里巴巴的开发手册标准:
高并发时,同步调用应该去考量锁的性能损耗。能用无锁数据结构,就不要用锁;能锁区块,就不要锁整个办法体;能用对象锁,就不要用类锁。
能够看到,只有在高并发才会思考到锁的性能问题,所以在一些小而全的零碎中,同步容器还是有用武之地的(当然也能够思考并发容器,前面章节再探讨)
附言:这不是洗白贴
目录
咱们这里分三步来剖析:
- 什么是同步容器
- 为什么要有同步容器
- 同步容器的优缺点
- 同步容器的应用场景
注释
1. 什么是同步容器
定义:就是把容器类同步化,这样咱们在并发中应用容器时,就不必手动同步,因为外部曾经主动同步了
例子:比方 Vector 就是一个同步容器类,它的同步化就是把外部的所有办法都上锁(有的重载办法没上锁,然而最终调用的办法还是有锁的)
源码:Vector.add
// 通过 synchronized 为 add 办法上锁
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
同步容器次要分两类:
- 一般类:Vector、Stack、HashTable
- 外部类:Collections 创立的外部类,比方 Collections.SynchronizedList、Collections.SynchronizedSet 等
那这两种有没有区别呢?
当然是有的,刚开始的时候 (Java1.0) 只有第一种同步容器(Vector 等)
然而因为 Vector 这品种太 局气 了,它就想着把所有的货色都弄过去本人搞(Vector 通过 toArray 转为己有,HashTable 通过 putAll 转为己有);
源码:Vector 构造函数
public Vector(Collection<? extends E> c) {
// 这里通过 toArray 将传来的汇合 转为己有
elementData = c.toArray();
elementCount = elementData.length;
// c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, elementCount, Object[].class);
}
所以就有了第二种同步容器类(通过工厂办法创立的外部容器类),它就比拟聪慧了,它只是把原有的容器进行包装(通过 this.list = list 间接指向须要同步的容器),而后部分加锁,这样一来,即生成了线程平安的类,又不必太费劲;
源码:Collections.SynchronizedList 构造函数
SynchronizedList(List<E> list) {super(list);
// 这里只是指向传来的 list,不转为己有,前面的相干操作还是基于原有的 list 汇合
this.list = list;
}
他们之间的区别如下:
两种同步容器的区别 | 一般类 | 外部类 |
---|---|---|
锁的对象 | 不可指定,只能 this | 可指定,默认 this |
锁的范畴 | 办法体(包含迭代) | 代码块(不包含迭代) |
适用范围 | 窄 - 个别容器 | 广 - 所有容器 |
这里咱们重点说下锁的对象:
- 一般类 锁的是以后对象this(锁在办法上,默认 this 对象);
- 外部类 锁的是 mutex 属性,这个属性默认是 this,然而能够通过 构造函数 (或工厂办法)来 指定锁 的对象
源码:Collections.SynchronizedCollection 构造函数
final Collection<E> c; // Backing Collection
// 这个就是锁的对象
final Object mutex; // Object on which to synchronize
SynchronizedCollection(Collection<E> c) {this.c = Objects.requireNonNull(c);
// 初始化为 this
mutex = this;
}
SynchronizedCollection(Collection<E> c, Object mutex) {this.c = Objects.requireNonNull(c);
this.mutex = Objects.requireNonNull(mutex);
}
这里要留神一点就是,外部类的迭代器没有同步(Vector 的迭代器有同步),须要手动加锁来同步
源码:Vector.Itr.next 迭代办法(有上锁)
public E next() {synchronized (Vector.this) {checkForComodification();
int i = cursor;
if (i >= elementCount)
throw new NoSuchElementException();
cursor = i + 1;
return elementData(lastRet = i);
}
}
源码:Collections.SynchronizedCollection.iterator 迭代器(没上锁)
public Iterator<E> iterator() {
// 这里会间接实现类的迭代器(比方 ArrayList,它外面的迭代器必定是没上锁的)return c.iterator(); // Must be manually synched by user!}
2. 为什么要有同步容器
因为一般的容器类(比方 ArrayList)是线程不平安的,如果是在并发中应用,咱们就须要手动对其加锁才会平安,这样的话就很麻烦;
所以就有了同步容器,它来帮咱们主动加锁
上面咱们用代码来比照下
线程不平安的类:ArrayList
public class SyncCollectionDemo {
private List<Integer> listNoSync;
public SyncCollectionDemo() {this.listNoSync = new ArrayList<>();
}
public void addNoSync(int temp){listNoSync.add(temp);
}
public static void main(String[] args) throws InterruptedException {SyncCollectionDemo demo = new SyncCollectionDemo();
// 创立 10 个线程
for (int i = 0; i < 10; i++) {
// 每个线程执行 100 次增加操作
new Thread(()->{for (int j = 0; j < 1000; j++) {demo.addNoSync(j);
}
}).start();}
}
}
下面的代码看似没问题,感觉就算有问题也应该是插入的程序比拟乱(多线程交替插入)
但实际上运行会发现,可能会报错数组越界,如下所示:
起因有二:
- 因为 ArrayList.add 操作没有加锁,导致多个线程能够同时执行 add 操作
- add 操作时,如果发现 list 的容量有余,会进行扩容,然而因为多个线程同时扩容,就会呈现扩容有余的问题
源码:ArrayList.grow 扩容
// 扩容办法
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
// 这里能够看到,每次扩容减少一半的容量
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
能够看到,扩容是基于之前的容量进行的,因而如果多个线程同时扩容,那扩容基数就不精确了,后果就会有问题
线程平安的类:Collections.SynchronizedList
/**
* <p>
* 同步容器类:为什么要有它
* </p>
*
* @author: JavaLover
* @time: 2021/5/3
*/
public class SyncCollectionDemo {
private List<Integer> listSync;
public SyncCollectionDemo() {
// 这里包装一个空的 ArrayList
this.listSync = Collections.synchronizedList(new ArrayList<>());
}
public void addSync(int j){// 外部是同步操作: synchronized (mutex) {return c.add(e);}
listSync.add(j);
}
public static void main(String[] args) throws InterruptedException {SyncCollectionDemo demo = new SyncCollectionDemo();
for (int i = 0; i < 10; i++) {new Thread(()->{for (int j = 0; j < 100; j++) {demo.addSync(j);
}
}).start();}
TimeUnit.SECONDS.sleep(1);
// 输入 1000
System.out.println(demo.listSync.size());
}
}
输入正确,因为当初 ArrayList 被 Collections 包装成了一个线程平安的类
这就是为啥会有同步容器的起因:因为同步容器使得并发编程时,线程更加平安
3. 同步容器的优缺点
一般来说,都是先说长处,再说毛病
然而咱们这次先说长处
长处:
- 并发编程中,独立操作是线程平安的,比方独自的 add 操作
毛病(是的,长处曾经说完了):
- 性能差,基本上所有办法都上锁,完满的诠释了“宁肯错杀一千,不可放过一个”
- 复合操作,还是不平安,比方 putIfAbsent 操作(如果没有则增加)
- 疾速失败机制,这种机制会报错提醒
ConcurrentModificationException
,个别呈现在当某个线程在遍历容器时,其余线程恰好批改了这个容器的长度
为啥第三点是毛病呢?
因为它只能作为一个倡议,通知咱们有并发批改异样,然而不能保障每个并发批改都会爆出这个异样
爆出这个异样的前提如下:
源码:Vector.Itr.checkForComodification 查看容器批改次数
final void checkForComodification() {
// modCount:容器的长度变动次数,expectedModCount:冀望的容器的长度变动次数
if (modCount != expectedModCount)
throw new ConcurrentModificationException();}
那什么状况下并发批改不会爆出异样呢?有两种:
- 遍历没加锁的状况:对于第二种同步容器 (Collections 外部类) 来说,假如线程 A 批改了 modCount 的值,然而没有同步到线程 B,那么线程 B 遍历就不会产生异样(但实际上问题曾经存在了,只是临时没有呈现)
- 依赖线程执行程序的状况:对于所有的同步容器来说,假如线程 B 曾经遍历完了容器,此时线程 A 才开始遍历批改,那么也不会产生异样
代码就不贴了,大家感兴趣的能够间接写几个线程遍历试试,多运行几次,应该就能够看到成果(不过第一种状况也是基于实践剖析,理论代码我这边也没跑进去)
依据阿里巴巴的开发标准:不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素请应用 Iterator 形式,如果并发操作,须要对 Iterator 对象加锁。
这里解释下,对于 List.remove 和 Iterator.remove 的区别
- Iterator.remove:会同步批改 expectedModCount=modCount
- list.remove:只会批改 modCount,因为 expectedModCount 属于 iterator 对象的属性,不属于 list 的属性(然而也能够间接拜访)
源码:ArrayList.remove 移除元素操作
public E remove(int index) {rangeCheck(index);
// 1. 这里批改了 modCount
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
源码:ArrayList.Itr.remove 迭代器移除元素操作
public void remove() {if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
// 1. 这里调用下面介绍的 list.romove,批改 modCount
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
// 2. 这里再同步更新 expectedModCount
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {throw new ConcurrentModificationException();
}
}
因为 同步容器 的这些毛病,于是就有了 并发容器(下期来介绍)
4. 同步容器的应用场景
多用在并发编程,然而并发量又不是很大的场景,比方一些简略的集体博客零碎(具体多少并发量算大,这个也是分很多状况而论的,并不是说每秒解决超过多少个申请,就说是高并发,还要联合吞吐量、零碎响应工夫等多个因素一起思考)
具体点来说的话,有以下几个场景:
- 写多读少,这个时候同步容器和并发容器的性能差异不大(并发容器能够并发读)
- 自定义的复合操作,比方 getLast 等操作(putIfAbsent 就算了,因为并发容器有默认提供这个复合操作)
- 等等
总结
- 什么是同步容器:就是把容器类同步化,这样咱们在并发中应用容器时,就不必手动同步,因为外部曾经主动同步了
- 为什么要有同步容器:因为一般的容器类(比方 ArrayList)是线程不平安的,如果是在并发中应用,咱们就须要手动对其加锁才会平安,这样的话就很太麻烦;所以就有了同步容器,它来帮咱们主动加锁
- 同步容器的优缺点:
长处 | 毛病 | |
---|---|---|
同步容器 | 独立操作,线程平安 | 复合操作,还是不平安 |
性能差 | ||
疾速失败机制,只适宜 bug 调试 |
- 同步容器的应用场景
多用在并发量不是很大的场景,比方集体博客、后盾零碎等
具体点来说,有以下几个场景:
- 写多读少:这个时候同步容器和并发容器差异不是很大
- 自定义复合操作:比方 getLast 等复合操作,因为同步容器都是单个操作进行上锁的,所以能够很不便地去拼接复合操作(记得内部加锁)
- 等等
参考内容:
- 《Java 并发编程实战》
- 《实战 Java 高并发》
后记
最初,感激大家的观看,谢谢
原创不易,期待官人们的三连哟
Java 并发 - 同步容器篇