并发的优化

并发的优化

避免共享的可变状态

避免共享状态理想的情况是构造无状态的程序,没有状态自然也就不会共享。一个典型的例子就是 Servlet 程序,各 Servlet 自身并不持有状态,彼此隔离,互不相扰。如果持有状态不可避免,则可以使用线程封闭技术,将状态’隐藏起来,不让别的线程访问到。常见的有栈封闭和 ThreadLocal 类两种形式。

栈封闭

栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量访问对象,这些局部变量被封闭在执行线程的栈内部,其它线程无法访问到它们。

public int loadTheArk(Collection<Animal> candidates) {
    SortedSet<Animal> animals;
    int numPairs = 0;
    Animal candidate = null;

    // animals confined to method, don't let them escape!
    animals = new TreeSet<Animal>(new SpeciesGenderComparator());
    animals.addAll(candidates);
    for (Animal a : animals) {
        // ...
    }
    return numPairs;
}

在上面的代码中,animals 和 candidate 是函数的局部变量,被封闭在栈帧内部,不会逸出,被其它线程访问到,所以该方法是线程安全的。

线程局部变量

ThreadLocal 类能使线程中的某个值与保存值的对象关联起来,在单个线程内部共享这个变量,而其它线程无法访问。一个典型的示例是数据库连接会话,将连接会话存储为 ThreadLocal 对象,线程内部共享同一个连接会话,不同线程之间的连接会话互不影响。

private static ThreadLocal<Connection> connectionHolder
        = new ThreadLocal<Connection>() {
            public Connection initialValue() {
                return DriverManager.getConnection(DB_URL);
            }
        };

public static Connection getConnection() {
    return connectionHolder.get();
}

避免可变状态

线程安全性是不可变对象的固有属性,对于不可变对象,所有线程看到状态必然是一致的。纯函数式编程语言中,没有变量,只有常量,状态不能被持有,只能通过函数参数来传递,所以是天然的线程安全。Java 没有这样得天独厚的基因,不可变类型需要自己实现,具体的实现方式可以参考《Effective Java》 “最小化可变性"这一节,概括来讲需要遵循以下 5 条原则:

  • 不要提供修改对象状态的方法
  • 确保这个类不能被继承
  • 把所有属性设置为 final
  • 把所有的属性设置为 private
  • 禁止访问类内部的可变域

Guava 库也提供了一组不可变类,比如 ImmutabelList、ImmutableSet 这些,我们应该在代码中尽可能地使用它们。

锁优化

锁是在高并发的环境下为了保证数据的正确性而产生的同步手段,为了进一步提升性能,我们就需要对锁进行优化。锁的优化思路有:从减小锁的持有时间、锁的粒度的优化、锁分离、锁消除、无锁等等。

减小锁的持有时间

减小锁的持有时间是为了降低锁的冲突的可能性,提高体系的并发能力。

  • 只在必要时进行同步加锁操作

例如下的代码:在加锁时先判断是否满足同步代码逻辑的要求,以达到减小锁的占有几率的目的。

// 使用条件判断减少锁持有时间提高效率。
public void matcher(Char input) {
    if (!compiled) {
        synchronized(this) {
            if (!compiled) {
                compile();
            }
        }
    }
}
  • 只在必须加锁的代码段加锁

下面的代码的执行只针对必须要加锁的代码段进行加锁操作,减少锁的占有的时间。

public synchronized void syncMethod() {
  method1();
  method2();
  method3();
}

public void syncMethod() {
  method1();
  synchronized(this) {
    method2();
  }
  method3();
}

锁粒度的优化

优化锁的粒度是根据实际的代码逻辑来进行判断,分为锁粒度的细化和锁粒度的粗化 2 种优化方式。

  • 锁粒度的细化

举个简单的例子,JDK 自带的工具类 ConcurrentHashMap 就是一个典型的实现场景,它对锁的拆分方式提高了大大提高了它的吞吐量,ConcurrentHashMap 将自身分成若干个段,每一段都是一个子 HashMap。当需要新增一个的时候,并不是对整个对象进行加锁,而是先根据 hashcode 计算该数据应该被加入到哪个段中,然后对该段加锁,默认情况下 ConcurrentHashMap 有 16 个段,因此运气足够好的时候可以接受 16 个线程同时插入,大大提高了吞吐量。

但是减小锁的粒度也带来了新的问题,当锁粒度过于小的时候,获取全局锁消耗的资源也相应增加,以 ConcurrentHashMap 为例,如果它需要获取当前的 size 就需要对每一个段都加锁。

  • 锁粒度的粗化

在一般情况下,为了保证多线程之间的高效并发,会要求线程持有锁的时间尽量短,但是过度的细化会产生大量的申请和释放锁的操作,这对性能的影响也是非常大的。如下所示:

for(int i = 0; i < 10000; i++) {
  synchronized(this) {
    todo();
  }
}
synchronized(this) {
    for(int i = 0; i < 10000; i++) {
      todo();
    }
}

锁分离

根据实际的操作来选择加上不同的锁也是提升性能的重要方式之一。

读写分离锁替代独占锁

ReadWriteLock 使用读写分离锁来替代独占锁,它也是减小锁的粒度的一种方式,上面讲的是对数据结构层面的减小锁持有时间的,这里是根据业务来划分锁的持有,在读多写少的场景使用读写分离锁会大大提高系统的并发性能。

重入锁和内部锁

重入锁的使用相较于内部锁更加复杂,重入锁必须手动显示释放锁,内部锁则可以自动释放,重入锁提供了一套提高性能的功能和 Condition 机制,重入锁可以设置锁的等待时间 boolean tryLock(long time),锁中断 lockInterruptibly() 和快速锁轮询 tryLock() 等可以有效的避免死锁的产生。内部锁则是通过 wait() 和 notfiy() 实现锁的控制。

自旋锁

自旋锁是 JVM 为了解决对多线程并发时频繁的挂起和恢复线程的操作问题的锁,当访问共享资源的时候,锁的等待时间可能很短,可能会比线程的挂起和恢复时间还要短,因此在这段时间里做线程的切换时不值得的。自旋锁可以使线程没有取得锁时不被挂起,而去执行一个空的循环,当线程获取了锁就会继续执行代码。

但是自旋锁只适用于线程竞争相对小、锁占用时间短的代码,对于锁竞争激烈的系统中不仅浪费了 CPU 资源,也免不了被挂起。JVM 可以设置自旋锁的开启和等待次数,防止一直执行空循环。

无锁

锁是一种对操作的同步手段,但是也不是唯一的手段,例如使用空间换时间的思路同样可以解决问题,非阻塞的同步方式也可以达到并发的目的。最简单的一种非阻塞的同步就是 ThreadLocal 了,每个线程有各自独立的 ThreadLocalMap,在并行计算时无需相互等待。另一种更为乐观的方式是使用 CAS 算法,它有 3 个参数(V,E,N),它总是认为自己的操作可以成功,因此只有在 V 的值等于 E 时,把 V 的值设置成 N;当 V 的值不等于 E,就返回 V 的当前值,然后什么也不做,当多个线程同时使用 CAS 时,只有一个线程会执行成功。

在 java.util.concurrent.atomic 包中有很多支持原子操作的类,都是基于无锁算法实现的,它的性能远远超过普通的有锁操作,例如使用 CAS 算法实现原子操作中的 getAndSet() 方法:

public final int getAndSet(int newValue) {
        for (;;) {                                       // 不停循环直到成功
            int current = get();                         // 获取当前的值
            if (compareAndSet(current, newValue)) {      // 若当前的值未受其他线程影响,则设置为新值
                return current;                          // 返回新值
            }
        }
    }

以时间换空间、以空间换时间都是实现代码的常用思路,在不同的地方应该使用不同的方式去达到业务需求。