我正在参与掘金创作者训练营第5期,点击了解活动详情
1、我们为什么需要锁
不熟悉多线程的同学可以参考我的文章 锁是如何保护并发安全性的?。
2、如何更好的使用锁?
使用锁可以保护在并发条件下的安全性,然而,如何更好地使用锁,使得并发程序有更好的性能是我们进一步需要关心的问题。
通常情况下,我们可以通过下面几种方法来优化锁:
- 缩小锁的使用范围
- 缩小锁的粒度
- 锁分段
- 避免热点域
2.1、缩小锁的使用范围
对比以下两段代码:
public class SyncMapStore {
private final HashMap<String,Integer> data=new HashMap<>();
public synchronized boolean insert(String name,Integer id) {
name="username:"+name;
if (data.containsKey(name)) {
data.put(name, id);
return true;
}
return false;
}
}
public class BetterSyncStore {
private final HashMap<String,Integer> data=new HashMap<>();
public boolean insert(String name,Integer id) {
name="username:"+name;
synchronized (this) {//将锁缩小至此
if (data.containsKey(name)) {
data.put(name, id);
return true;
}
}
return false;
}
}
两段代码都实现了在并发下安全地向HashMap中插入键值对,但是下面的代码有着更好地性能: insert()方法中,除了使用HashMap的代码不需要加锁,通过缩小锁的范围,减少了程序持有锁的时间,达到了更好的并发性能
2.2、缩小锁的粒度
对比以下两段代码:
public class SyncQuery {
private final HashSet<String> names=new HashSet<>();
private final HashSet<Integer> ids=new HashSet<>();
public synchronized void addName(String name) {
names.add(name);
}
public synchronized void addId(Integer id) {
ids.add(id);
}
public synchronized boolean queryName(String name) {
if (names.contains(name)) {
return true;
}
return false;
}
public synchronized boolean queryId(Integer id) {
if (ids.contains(id)) {
return true;
}
return false;
}
}
public class BetterSyncQuery {
private final HashSet<String> names=new HashSet<>();
private final HashSet<Integer> ids=new HashSet<>();
public void addName(String name) {
synchronized (names) {
names.add(name);
}
}
public void addId(Integer id) {
synchronized (ids) {
ids.add(id);
}
}
public boolean queryName(String name) {
synchronized (names) {
if (names.contains(name)) {
return true;
}
}
return false;
}
public boolean queryId(Integer id) {
synchronized (ids) {
if (ids.contains(id)) {
return true;
}
}
return false;
}
}
以上两段代码都实现了向HashSet中储存与查询name和id的功能。
而两者的区别是前者所有方法都以 this 为锁,而后者针对对names和ids的操作,分别以names和ids作为方法的锁。
这样做有什么好处呢?
- 如果都以this为锁的话,对names和ids的操作都会持有this为锁,也就是说names与ids不能同时被操作。
- 而分别用names和ids作为对应方法的锁,由于不同方法持有的锁不同,因此可以同时使用,增大了并发性。在两者调用频率相近的理想情况下,可以将并发效率提升一倍。
2.3、锁分段
以对HashMap的操作为例,在多线程情况下,如果每次修改Map都是以this作为锁,那么在并发条件下,至多只能由一个线程对HashMap进行修改,这样的效率是很低的。
因此在ConcurrentHashMap中给出了这样的解决方案:
为每个桶维护一个锁,每次修改Map时,获取各个桶上的锁而不是ConcurrentHashMap的对象锁,这种方式将大的对象锁分解为了小的桶锁,大大减小了不同线程争夺相同锁的概率,提高了并发性。
通过锁分解将一个独立对象上的锁分解为多个锁,即锁分段
2.4、避免热点域
依旧举HashMap的例子,在计算HashMap的大小时,一种常见的作法是在HashMap中维护一个size值,每次插入新键值对时将其加一,反之减一。
在单线程情况下,这种方式是没什么问题的,能够给予较高的性能。
而在多线程情况下,这种方式却会暴露较大缺陷:如果有多个进程对HashMap进行操作,当有进程对HashMap执行添加或者修改操作时,其他线程都无法获得size,这对高并发下的效率影响是巨大的。
size这类在多个线程中频繁访问的字段叫做热点域(Hot Field),我们编写代码时应该尽量避开热点域,以减少锁的竞争。
而在ConcurrentHashMap中给出了这样的解决方案:
为每个桶维护一个计数器,在调用size()方法时对各个桶的计数器进行累加。这样做的好处是将热点数据分散到各个桶中,减少各线程持有锁的时间。
- 此外,ConcurrentHashMap还维护了一个volatile变量作为缓存,每次调用size()时将结果缓存到volatile变量中。
- 如果在两次size()调用中对容器进行修改,会将这个volatile变量置为-1,并且在后一个size()调用时重新计算。
- 否则直接返回volatile变量中缓存的值。
2.5、使用独占锁以外的方法
- 读-写锁:使用独占锁时,当一个线程在修改共享资源时其他线程都不能访问该资源;在某些情况下可以使用读-写锁代替。即运行同时有一个线程修改共享资源,或多个线程可以阅读共享资源,但前两者不能同时出现。
- 用原子变量代替普通变量:原子变量提供了细粒度的原子操作,并且使用了底层并发原语支持,能够更好地提降低并发操作的时间开销。
- etc
3、小结
本文简单介绍了几种优化锁的方法,在多线程中可以通过它们对代码进行优化。当然,写出良好的并发代码还需要大量的实践经验,笔者接触并发编程的时间也不长,望共勉,文章如有不足之处亦望不吝斧正。