一、对象组合与安全委托
1. 实例封闭技术
为了保证并发操作场景下实例访问的安全性,我们可利用组合的方式将实例委托给其它实例,即基于该委托类对外暴露实例的部分操作,封闭风险调用,确保对象访问时是安全且一致的。就像下图这样,将obj委托给delegate进行管理,将set操作封闭不对外暴露,确保仅通过暴露只读避免对象逸出:
对应的,如果我们想实现一个线程安全的HashMap缓存的安全发布和访问,对应落地技巧为:
- HashMap实例私有封闭
- 基于final保证HashMap域的不可变
- 采用同一粒度的类锁发布HashMap的读写操作一致和安全,同时保证外部不可直接操作cache
如下所示,我们隐藏了HashMap部分操作,同时基于监视锁synchronized 保证读写操作可见且安全:
typescript
public class Cache {
//实例私有并在内部完成初始化
private static final Map<String, Object> cache = new HashMap<>();
public static synchronized void put(String key, Object object) {
cache.put(key, object);
}
public static synchronized Object get(String key) {
return cache.get(key);
}
}
需要注意的时,笔者上文强调的是被委托的容器cache的安全,基于get方法访问到object还是会被发布出去,此时就可能在并发操的线程安全问题:
所以如果开发人员需要保证读取对象的安全,建议用存储的值也采用final修饰一下后存入容器中。
arduino
public static void main(String[] args) {
final User user = new User(4,"val-4");
put("k-1", user);
}
private static class User{
//使用final修饰保证对应成员域不可修改
private final int id;
private final String name;
public User(int id, String name) {
this.id = id;
this.name = name;
}
}
2. 基于监视器模式的对象访问
从线程封闭原则及逻辑推论可以得出java监视器模式,对于并发操作下的对象读访问,我们可以采用监视器模式将可变状态加以封装,我们以常用的java list为例,整体封装思路为:
- 将需要管理的被委托的List以不可变的成员域的方式组合到SafeList 中
- 使用final保证列表安全初始化且不可变
- List选用不可变列表,做好安全兜底,避免顺序等遭到破坏
- 屏蔽所有容器的删改操作
- 访问对象在进行必要性校验后,返回深拷贝的对象,不暴露容器内部细节
对应的代码如下所示:
java
public class SafeList {
//final修饰保证list安全初始化
private final List<Person> list;
public SafeList(List<Person> list) {
//使用不可变方法为容器做好安全兜底,保证列表不可进行增、闪、删、改操作
this.list = Collections.unmodifiableList(list);
}
//通过拷贝将对象安全发布出去,因为只读所以无需上锁
public Person getPerson(int idx) {
if (idx >= list.size()) {
throw new RuntimeException("index out of bound");
}
Person person = list.get(idx);
return new Person(person.getId(),person.getName());
}
}
对应为了保证代码的完整性我们也给出Person 类的实现:
arduino
public class Person {
private int id;
private String name;
public Person(int id, String name) {
this.id = id;
this.name = name;
}
//get set ......
}
3. 对象不可变性简化委托
基于监视器模式我们可以很好的保证对象的安全访问,实际上我们可以做好更好,上文通过实例封闭和仅只读权限保证容器的并发操作安全,同时在只读操作返回Person 时我们也用了深拷贝发布一个全新的实例出去,保证容器内部的元素不可变,实际上如果我们能够将Person 属性保证不可变的情况下将其委托给容器,访问操作也可以直接返回:
arduino
public class Person {
private final int id;
private final String name;
public Person(int id, String name) {
this.id = id;
this.name = name;
}
}
由此我们的代码就可以简化成下面这样,因为避免的对象拷贝的过程,程序性能也得到提升:
csharp
public Person getPerson(int idx) {
if (idx >= list.size()) {
throw new RuntimeException("index out of bound");
}
//person字段不可变,可直接返回
return list.get(idx);
}
对应的我们基于下属代码针对Person拷贝发布和只读封装两种模式进行压测,对应结果为:
- 拷贝发布因为拷贝的开销耗时353ms
- 采用只读发布的耗时为152ms
ini
//生成测试样本
List<Person> personList = IntStream.rangeClosed(1, 500_0000).parallel()
.boxed()
.map(i -> new Person(i, RandomUtil.randomString(10)))
.collect(Collectors.toList());
//生成安全容器
SafeList safeList = new SafeList(personList);
//进行并发访问压测
int threadSize = 1000;
CountDownLatch countDownLatch = new CountDownLatch(threadSize);
ExecutorService threadPool = Executors.newFixedThreadPool(threadSize);
long begin = System.currentTimeMillis();
for (int i = 0; i < threadSize; i++) {
threadPool.execute(() -> {
Person person = safeList.getPerson(RandomUtil.randomInt(500_0000));
boolean b = 1 != 1;
if (b) {
Console.log(JSONUtil.toJsonStr(person));
}
countDownLatch.countDown();
});
}
countDownLatch.await();
long end = System.currentTimeMillis();
//计算输出耗时
Console.log("cost:{}ms", end - begin);
//关闭线程池
threadPool.shutdownNow();
4. 原子维度的访问
如果我们被委托的对象是要求可变的,那么我们就需要保证所有字段的操作是互斥且原子的。例如我们现在要委托给容器一个坐标对象,因为坐标的值会实时改变的,所以在进行坐标操作时,我们必须保证读写的一致性,即set和get都必须一次性针对x、y,从而避免当为非原子操作读取操一些异常的做坐标。
将两者分开处理则可能会因为非原子操作在并发情况下看到一个非法的逻辑坐标,例如:
- 坐标发生改变,线程0进入修改,调用setX修改x坐标。
- 线程2访问,看到一个修改后的x和未修改的y,定位异常。
- 线程1修改y坐标。
正确的坐标设置方式如下代码所示,即x、y保证同时进行读写保证正确的坐标更新与读取:
arduino
public class SafePoint {
private int x;
private int y;
public SafePoint(int x, int y) {
this.x = x;
this.y = y;
}
//原子维度操作保证操作的一致性
public synchronized void setXandY(int x, int y) {
this.x = x;
this.y = y;
}
//原子返回保证x、y,保证看到x、y实时一致修改后的值
public synchronized int[] getXandY() {
return new int[]{x, y};
}
}
所以对于相关联的字段,除了必要的同步锁操作,我们还需要在将操作进行原子化,保证读取数据的实时正确一致。
二、现有容器的并发安全的封装哲学
1. 使用继承
Java类库中内置了许多见状的基础模块类,日常使用时我们应该优先重要这些类,然后在此基础上将类进行拓展封装,例如我们基于古典的线程安全列表vector实现一个若没有对应元素则添加的操作:
scala
public class BetterVector extends Vector {
//通过继承获取vector的api完成如果没有则添加的线程安全原子操作
public synchronized void addIfAbsent(Object o) {
if (!contains(o)) {
super.add(o);
}
}
}
当然这种方法也是存在风险的:
- 它暴露了vector的其他方法
- 开发者如果对于BetterVector没有详细的了解的话,可能还是会将contain和add操作错误的组合使用,操作一致性问题。
2. 使用组合
所以我们推荐实用组合的方式,通过将需要拓展的容器以组合的方式屏蔽内置容器的实现细节:
arduino
private List<Person> list = new ArrayList<>();
public synchronized void addIfAbsent(Person person) {
if (list.isEmpty()) {
list.add(person);
}
}
但需要注意对于组合操作下操作粒度锁的把控,例如下面这段代码:
arduino
public class SafeList {
private final List<Person> list;
public SafeList(List<Person> list) {
this.list = Collections.synchronizedList(list);
}
//当前方法锁的粒度是被委托的实例
public synchronized void addIfAbsent(Person person) {
if (list.isEmpty()) {
list.add(person);
}
}
public void add(Person person) {
//add操作查看底层源码用的锁是 mutex = this;
list.add(person);
}
}
咋一看没什么问题,本质上都是上了锁,实际上add和addIfAbsent用的是两把锁:
- addIfAbsent用的是当前SafeList实例作为锁
- 而add因为直接复用add方法所以用的是synchronizedList的对象锁
这就使得addIfAbsent操作不是原子的,即在addIfAbsent操作期间,其他线程是可以直接调用list的api:
所以正确的做法是基于被组合安全容器的锁,构建相同维度的拓展方法:
csharp
private List<Person> list = Collections.synchronizedList(new ArrayList<>());
//当前方法锁的粒度是被委托的实例
public void addIfAbsent(Person person) {
synchronized (list) {
if (list.isEmpty()) {
list.add(person);
}
}
}
public void add(Person person) {
//add操作查看底层源码用的锁是 mutex = this;
list.add(person);
}