并发编程实战 - 性能和可伸缩性、显式锁

一、性能和可伸缩性

1、可伸缩性:当增加计算资源时(例如CPU、内存、存储容量或IO带宽),程序的吞吐量或者处理能力相应地增加。

2、在并发程序中,对可伸缩性的主要威胁就是独占式的资源锁。我们知道,串行操作会降低可伸缩性,并且上下文切换也会降低性能。在锁上竞争时将同时导致这两种问题,因此减少锁的竞争能够提高性能和可伸缩性。

3、有三种方式可以降低锁的竞争程度:

a)减少锁的持有时间;

【缩小锁的范围{同步代码块 - 而不是修饰方法}、减小锁的粒度{锁分解和锁分段等技术 - 采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况 - 这些技术能减小锁操作的粒度,并能实现更高的可伸缩性,然而,使用的锁越多,那么发生死锁的风险就越高}】

-  锁分解

如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。

设想一下,如果在整个程序中只有一个锁,而不是为每个对象分配一个独立的锁,那么,所有同步代码块的执行就会变成串行化执行,而不考虑各个同步块中的锁。由于很多线程将竞争同一个全局锁,因此两个线程同时请求这个锁的概率将剧增,从而导致严重的竞争。

  public class ServerStatus{
        public final Set<String> users;
        public final Set<String> queries;

        public synchronized void addUser(String u){
            users.add(u);
        }

        public synchronized void addQuery(String u){
            queries.add(u);
        }
        ......//其他方法
    }
上述整个程序使用一个锁,这样的程序多个线程请求同一个锁的概率将剧增。

    public class ServerStatus{
        public final Set<String> users;
        public final Set<String> queries;

        public  void addUser(String u){
           synchronized (users){
               users.add(u);
           }
        }

        public synchronized void addQuery(String u){
            synchronized (queries){
                queries.add(u);
            }
        }
        ......//其他方法
    }
如果在锁上存在适中而不是激烈的竞争时,通过将一个锁分解为两个锁,能最大限度地提升性能。如果对竞争并不激烈的锁进行分解,那么在性能和吞吐量等方面带来的提升将非常有限。对竞争适中的锁进行分解时,实际上是把这些锁转变为非竞争的锁,从而有效地提升性能和可伸缩性。

-  锁分段

把一个竞争激烈的锁分解为两个锁时,这两个锁可能都存在激烈的竞争。在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。典型的示例就是ConcurrentHashMap,在ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/6,其中第N个散列桶由第(N mod 16)个锁保护。

锁分段的一个劣势在于:与采用单个锁来实现独占式访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多只需获取一个锁,但在某些情况下需要加锁整个容器,例如当ConcurrentHashMap需要扩展映射范围,以及重新计算键值的散列值要分布到更大的桶集合中时,就需要获取分段锁集合中的所有的锁。

    public class StripedMap{
        //同步策略:buckets[n]由locks[n%N_LOCKS来保护]
        private static final int N_LOCKS = 16;
        private final Node[] buckets;
        private final Object[] locks;
        
        private static class Node{......}
        
        public StripedMap(int numBuckets){
             buckets = new Node[numBuckets];
            locks = new Object[N_LOCKS];
            for (int i = 0;i < N_LOCKS; i++){
                locks[i] = new Object();
            }
        }
        
        private final int hash(Object key){
            return Math.abs(key.hashCode() % buckets.length);
        }
        
        public Object get(Object key){
            int hash = hash(key);
            synchronized (locks[ hash % N_LOCKS]){
                ....
            }
            ...
        }
        
        public void clear(){
            for (int i = 0;i<buckets.length;i++){
                synchronized (locks[i % N_LOCKS]){
                    buckets[i] = null;
                }
            }
        }
        ......
    }

b)降低锁的请求频率;

c)使用带有协调机制的独占锁,这些机制允许更高的并发性;

第三种降低竞争锁影响的技术就是放弃使用独占锁,从而有助于使用一种友好并发的方式来管理共享状态。例如,使用并发容器、读-写锁、不可变对象以及原子变量。

ReadWriteLock实现了一种在多个读操作以及单个写操作情况下的加锁规则:如果多个读取操作都不会修改共享资源,那么这些读取操作可以同时访问该共享资源,但在执行写入操作时必须以独占方式来获取锁。

原子变量提供了一种方式来降低更新“热点域”时的开销。

4、向“对象池”说不

现在Java的分配操作已经比C语言的malloc调用更快,在并发程序中,对象池表现更加槽糕。当线程分配新的对象时,基本上不需要再线程之间进行协调,因为对象分配器通常会使用线程本地的内存块,所以不需要再堆数据结构上进行同步。然而,如果这些线程从对象池中请求一个对象,那么就需要通过某种同步来协调对象池数据结构的访问,从而可能是某个线程被阻塞。对象池有其特定的用途,但对于性能优化来说,用途很有限。通常,对象分配操作的开销比同步的开销更低。

二、显式锁

在Java 5.0之前,在协调对共享对象的访问时可以使用的机制只有synchronized和volatile。Java 5.0 增加了一种新的机制:ReentrantLock。与之前提到过得机制相反,ReentrantLock并不是一种替代内置锁的方法,而是当内置锁机制不适用时,作为一种可选择的高级功能。

2.1、Lock与ReentrantLock


Lock接口提供了一种无条件的、可轮询的、定时的以及可中断的锁获取操作,所有加锁和解锁的方法都是显式的。在Lock的实现中必须提供与内部锁相同的内存可见性语义,但在加锁语义、调度算法、顺序保证以及性能特性等方面可以有所不同。

ReentrantLock实现了Lock接口,并提供了与synchronized相同的互斥性和内存可见性。与synchronized一样,ReentrantLock还提供了可重入的加锁语义,与synchronized相比,它还为处理锁的不可用性问题提供了更高的灵活性。

Lock lock = new ReentrantLock();
        ...
        lock.lock();
        try{
            ......
        }finally {
            lock.unlock();
        }
如果没有使用finally来释放Lock,那么就相当于启动了“ 定时炸弹”,当 定时炸弹 ”爆炸时,将很难追踪到最初发生错误的位置,因为没有记录应该释放锁的位置和时间,这就是ReentrantLock不能完全替代synchronized的原因:它更加危险,因为当程序执行控制离开被保护的代码块时,不会自动清除锁。虽然在finally块中释放锁并不困难,但也可能忘记。

2.2、轮询锁与定时锁 - tryLock

在内置锁中,死锁是一个严重的问题,恢复程序的唯一方法就是重新启动程序,而防止死锁的唯一方法就是在构造程序时避免出现不一致的锁顺序。可定时与可轮询的锁提供了另外一种选择:避免死锁的发生。
如果不能获得所有需要的锁,那么可以使用可定时或可轮询的锁获取方式,从而重新获得控制权,它会释放已经获得的锁,然后重新尝试获取所有锁。
 while(true){//轮询
            //Account中的lock变量
            if (fromAccount.lock.tryLock()){
                if (toAccount.lock.tryLock()){
                    
                }finally{
                    toAccount.lock.unlock();
                }
            }finally{
                fromAccount.lock.unlock();
            }
        }
定时的tryLock能够在这种带有时间限制的操作中实现独占式加锁行为。
if(!lock.tryLock(3000,NANOSECONDS)){
           return false;
       }
        try{
           ......
        }finally {
            lock.unlock();
        }

2.3、可中断的锁获取操作

正如定时的锁获取操作能在带有时间限制的操作中使用独占锁,可中断的锁获取操作同样能在可取消的操作中使用加锁。lockInterruptibly方法【定时的tryLock也能响应中断】能在获得锁的同时保持对中断的响应。并且由于它包含在Lock中,因此无须创建其他类型的不可中断阻塞机制。
      try{
            try{
                lock.lockInterruptubly();
            }catch(Exception e){
                e.printStackTrace();
            }
        }finally {
            lock.unlock();
        }

2.4、内置锁与ReentrantLock之性能比较

在Java 5.0 中,ReentrantLock能提供更高的吞吐量,但在Java 6 中,二者的吞吐量非常接近了。因为Java6使用了改进后的算法来管理内置锁。

2.5、公平性

在ReentrantLock的构造函数中提供了两种公平性选择:创建一个非公平(默认)的锁或者一个公平的锁。在公平锁上,线程将按照它们发出的请求的顺序来获得锁,但在非公平的锁上,则允许“插队”:当一个线程请求非公平锁时,如果在发出请求的同时该锁的状态变为可用,那么这个线程将跳过队列中所有的等待线程并获得这个锁。
在大多数情况下,非公平锁的性能要高于公平锁的性能。

2.6、synchronized和ReentrantLock之间的选择

在一些内置锁无法满足需求的情况下,ReentrantLock可以作为一种高级选择工具。当需要一些高级功能时才应该使用ReentrantLock,这些功能包括:可定时的、可轮询的与可中断的锁获取操作,公平队列,以及非块结构锁。否则,还是应该使用synchronized。

2.7、读 - 写锁

只要每个线程都能确保读取到最新的数据,并且在读取数据时不会有其他的线程修改数据,那么就不会发生问题。在这种情况下就可以使用读/写锁:一个资源可以被多个读操作访问,或者被一个写操作访问,但两者不能同时进行。
ReadWriteLock中暴露了两个Lock对象,其中一个用于读操作,而另一个用于写操作。尽管这两个锁看上去彼此是独立的,但读取锁和写入锁只是读 - 写锁对象的不同视图。
public interface ReadWriteLock {
    /**
     * Returns the lock used for reading.
     *
     * @return the lock used for reading.
     */
    Lock readLock();

    /**
     * Returns the lock used for writing.
     *
     * @return the lock used for writing.
     */
    Lock writeLock();
}
在读-写锁实现的加锁策略中,允许多个读操作同时进行,但每次只允许一个写操作。在实际情况中,对于多处理器系统上被频繁读取的数据结构,读-写锁能够提高性能。而在其他情况下,读-写锁的性能比独占锁的性能略差一些,这是因为它们的复杂性更高。
ReentrantReadWriteLock为这两种锁都提供了可重入的加锁语义。 ReentrantReadWriteLock在构造时也可以选择是一个非公平的锁还是一个公平的锁。
当锁的持有时间较长并且大部分操作都不会修改被守护的资源时,那么读 - 写锁能提高并发性。

猜你喜欢

转载自blog.csdn.net/json_it/article/details/79186040