在多线程并发下如何减少锁的竞争?

426 阅读4分钟

我正在参与掘金创作者训练营第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的操作,分别以namesids作为方法的锁。

这样做有什么好处呢?

  • 如果都以this为锁的话,对names和ids的操作都会持有this为锁,也就是说names与ids不能同时被操作。
  • 而分别用namesids作为对应方法的锁,由于不同方法持有的锁不同,因此可以同时使用,增大了并发性。在两者调用频率相近的理想情况下,可以将并发效率提升一倍。

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、小结

本文简单介绍了几种优化锁的方法,在多线程中可以通过它们对代码进行优化。当然,写出良好的并发代码还需要大量的实践经验,笔者接触并发编程的时间也不长,望共勉,文章如有不足之处亦望不吝斧正。