Java多线程笔记(二):锁与闭锁工具类

 约 50 分钟

为了更好地支持并发程序,“锁”是较为常用的同步方法之一。在高并发环境下,激励的锁竞争会导致程序的性能下降。
所以我们将在这里讨论一些有关于锁使用和问题以及一些注意事项。

工具类

ReentrantLock

重入锁可以完全替代Synchronized关键字,但其必须显式的调用unlock。建议视为Synchronized的高级版,比起Synchronized关键字,其可定时、可轮询并含有可中断的锁获取操作,公平队列以及非块结构的锁。

/**
 * 重入锁演示
 *
 */
public class ReeterLock implements Runnable{

    public static ReentrantLock lock = new ReentrantLock();

    public static int i = 0;

    @Override
    public void run() {
        for (int j=0;j<10000;j++){
            //手动上锁,可以上N把,这里是为了演示
            lock.lock();
            lock.lock();
            lock.lock();
            try {
                i ++;
            } finally {
                //无论如何必须释放锁,上几把 释放几把
                lock.unlock();
                lock.unlock();
                lock.unlock();
            }
        }
    }

    public static void main(String[] a) throws InterruptedException {
        ReeterLock rl = new ReeterLock();
        Thread t1 = new Thread(rl);
        Thread t2 = new Thread(rl);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.print(i);
    }
}

那么我们可以明显的看到重入锁保护着临界区资源i,确保多线程对i操作的安全。在demo中我们也是加了3次锁并释放了3次锁。

需要注意的是,如果同一线程多次获得锁,那么在释放锁的时候,也必须释放相同次数。如果释放的次数多了,会得到一个java.lang.IllegalMonitorStateException异常;反之则会导致当前线程一直持有该锁,导致其他线程无法进入临界区。

中断响应:ReentrantLock.lockInterruptibly()

对于synchronized来说,如果一个线程在等待锁,那么结果只有两种情况,要么它获得这把锁继续执行,要么它就保持等待。而使用重入锁,则提供另外一种可能,那就是线程可以被中断。也就是在等待锁,程序可以根据需要取消对锁的请求。有些时候,这么做是非常有必要的。

lockInterruptibly()方法是一个可以对中断进行响应的锁申请动作,即在等待锁的过程中,中断响应。

public class IntLock implements Runnable{

    public static ReentrantLock lock1 = new ReentrantLock();
    public static ReentrantLock lock2 = new ReentrantLock();
    int lock;

    /**
     * 控制加锁顺序,制造死锁
     * @param lock
     */
    public IntLock(int lock) {
        this.lock = lock;
    }

    @Override
    public void run() {
        try {
            /**
             *  1号线程,先占用 1号锁,再申请 2号锁
             *  2号线程,先占用 2号锁,再申请 1号锁
             *  这样就很容易造成两个线程相互等待.
             */
            if (lock == 1){
                //加入优先响应中断的锁
                lock1.lockInterruptibly();
                System.out.println(Thread.currentThread().getName() + "  进入...");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                /**
                 * 这时候,1号线程 想要持有 2号锁 ,但是2号线程已经先占用了2号锁,所以1 号线程等待.
                 * 2号线程也一样,占用着2号锁 不释放,还想申请1号锁,而1号锁 被1号线程占用且不释放.
                 */
                lock2.lockInterruptibly();
                System.out.println(Thread.currentThread().getName() + "  完成...");

            }else {
                lock2.lockInterruptibly();
                System.out.println(Thread.currentThread().getName() + "  进入...");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                lock1.lockInterruptibly();
                System.out.println(Thread.currentThread().getName() + "  完成...");
            }
        } catch (InterruptedException e) {
            System.out.println(Thread.currentThread().getName() + "  被中断,报异常...");
            e.printStackTrace();
        } finally {
            if (lock1.isHeldByCurrentThread()) {
                System.out.println(Thread.currentThread().getName() + "  释放...");
                lock1.unlock();
            }
            if (lock2.isHeldByCurrentThread()) {
                System.out.println(Thread.currentThread().getName() + "  释放...");
                lock2.unlock();
            }
            System.out.println(Thread.currentThread().getName() + " 线程退出...");
        }
    }

    public static void main(String[] a) throws InterruptedException {
        IntLock re1 = new IntLock(1);
        IntLock re2 = new IntLock(2);
        Thread t1 = new Thread(re1," 1 号线程 ");
        Thread t2 = new Thread(re2," 2 号线程 ");
        t1.start();
        t2.start();
        //主线程sleep 2秒,让两个线程相互竞争资源.造成死锁
        Thread.sleep(2000);
        //中断2号线程
        t2.interrupt();

        /* 执行结果:

            1 号线程   进入...
            2 号线程   进入...
            2 号线程   被中断,报异常...    // 执行 t2.interrupt();
            java.lang.InterruptedException
            2 号线程   释放...
            at java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireInterruptibly(AbstractQueuedSynchronizer.java:898)
            2 号线程  线程退出...
            at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireInterruptibly(AbstractQueuedSynchronizer.java:1222)
            1 号线程   完成...  // 只有1号线程能执行完成
            at java.util.concurrent.locks.ReentrantLock.lockInterruptibly(ReentrantLock.java:335)
            1 号线程   释放...
            at com.iboray.javacore.Thread.T3.IntLock.run(IntLock.java:55)
            1 号线程   释放...
            at java.lang.Thread.run(Thread.java:745)
            1 号线程  线程退出...
        */


    }
}

锁申请等待限时:ReentrantLock.tryLock

除了等待外部通之外,避免死锁还有另外一种方法,就是限时等待,给定一个等待时间让线程自动放弃。

  • tryLock(时长,计时单位),若超过设定时长还没得到锁就返回false,若成功获得锁就返回true。
  • tryLock(),若没有参数,当前线程会尝试获得锁,如果申请锁成功,则返回true,否则立即返回false。这种模式不会引起线程等待,因此不会产生死锁。
public class TimeLock implements Runnable{

    public static ReentrantLock lock = new ReentrantLock();

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " 申请资源...");
        try {
            //申请3秒,如果获取不到,返回false,退出.
            if (lock.tryLock(5, TimeUnit.SECONDS)) {
                System.out.println(Thread.currentThread().getName() + " 获得资源,开始执行...");
                //持有锁6秒
                Thread.sleep(6000);
                System.out.println(Thread.currentThread().getName() + " 执行完成...");
            }else {
                System.out.println(Thread.currentThread().getName() + " 申请锁失败...");
            }
        } catch (InterruptedException e) {
            System.out.println(Thread.currentThread().getName() + " 中断...");
            e.printStackTrace();
        }finally {
            if (lock.isHeldByCurrentThread()) {
                System.out.println(Thread.currentThread().getName() + " 释放锁...");
                lock.unlock();
            }
        }

    }

    public static void main(String[] a) throws InterruptedException {
        TimeLock re = new TimeLock();
        Thread t1 = new Thread(re," 1 号线程 ");
        Thread t2 = new Thread(re," 2 号线程 ");
        t1.start();
        t2.start();

        /*
        执行结果:

            1 号线程  申请资源...
            2 号线程  申请资源...
            1 号线程  获得资源,开始执行...
            2 号线程  释放锁...  //等待了5秒后,依然申请不到锁,就返回false
            1 号线程  执行完成...
            1 号线程  释放锁...
        */
    }
}

由于占用锁的线程会持有锁长达6秒,故另一个线程无法在5秒的等待时间内获取锁,因此,请求锁会失败。

公平锁:ReentrantLock(true)

在大多数情况下,锁的申请都是非公平的。也就是说,线程1首先请求了锁A,接着线程2也请求了锁A。那么当锁A可用时,是线程1还是线程2可以获得锁呢?显然这是不一定的。系统只会从这个锁的等待队列中随机挑选一个,因此不能保证其公平性。

公平锁会按照实际的先后顺序,保证先到先得,它不会产生饥饿,只要排队,最终都可以等到资源。在创建重入锁时,通过有参构造函数,传入boolean类型的参数,true表示是公平锁。实现公平所必然要维护一个有序队列,所以公平锁的实现成本高,性能相对也非常低,默认情况下,锁是非公平的。

public class ReentrantLockExample3 implements Runnable{

    //创建公平锁
    public static ReentrantLock lock = new ReentrantLock(true);

    static  int i = 0;

    @Override
    public void run() {

        for (int j = 0;j<5;j++){
            lock.lock();
            try {
                i++;
                System.out.println(Thread.currentThread().getName() + " 获得锁 " + i);
            } finally {
                lock.unlock();
            }
        }

    }

    public static void main(String[] a) throws InterruptedException {
        ReentrantLockExample3 re = new ReentrantLockExample3();
        Thread t1 = new Thread(re," 1 号线程 ");
        Thread t2 = new Thread(re," 2 号线程 ");
        Thread t3 = new Thread(re," 3 号线程 ");
        Thread t4 = new Thread(re," 4 号线程 ");
        t1.start();
        t2.start();
        t3.start();
        t4.start();

        /*
        执行结果:

        1 号线程  获得锁 1
        2 号线程  获得锁 2
        3 号线程  获得锁 3
        4 号线程  获得锁 4
        1 号线程  获得锁 5
        2 号线程  获得锁 6
        3 号线程  获得锁 7
        4 号线程  获得锁 8
        .....
        4 号线程  获得锁 16
        1 号线程  获得锁 17
        2 号线程  获得锁 18
        3 号线程  获得锁 19
        4 号线程  获得锁 20
        */

    }
}

ReentrantLock的以上几个重要的方法

  • lock() 获取锁,如果锁被占用,则等待
  • lockInterruptibly() 获取锁,但优先响应中断
  • tryLock() 尝试获取锁,如果成功返回true,否则返回false。该方法不等待,立即返回。
  • tryLock(long time,TimeUnit unit) 在给定时间内获取锁。
  • unlock() 释放锁。

就重入锁实现来看,它主要集中在Java 层面。在重入锁实现中,主要包含三个要素:

  1. 原子状态。原子状态使用CAS操作来存储当前锁的状态,判断锁是否已经被别的线程持有。
  2. 等待队列。所有没有请求成功的线程都进入等待队列进行等待。当有线程释放锁后,系统就从当前等待队列中唤醒一个线程继续工作。
  3. 阻塞原语park()和unpack(),用来挂起和恢复线程。没有得到锁的线程将会被挂起。

重入锁的好搭档:Condition条件

如果大家理解了Object.wait()Object.notify()方法的话,就能很容易地理解Condition对象了。它和wait()和notify()方法的作用是大致相同的。但是wait()方法和notify()方法是和synchronized关键字合作使用的,而Condtion是与重入锁相关联的。通过Lock接口(重入锁就实现了这个接口)的Condtion newCondition()方法可以生成一个与当前重入锁绑定的Condition实例。利用Condition对象,我们就可以让线程在合适的时间等待,或者在某一个特定的时刻得到通知,继续执行。

Condition接口提供的基本方法如下:

void await() throws InterruptedException;
void awaitUninterruptibly();
long awaitNanos(long nanosTimeout) throws InterruptedException;
boolean await(long time, TimeUnit unit) throws InterruptedException;
boolean awaitUntil(Date deadline) throws InterruptedException;
void signal();
void signalAll();

以上方法含义如下

  • await()方法会使当前线程等待,同时释放当前锁,当其他线程中使用signal()或者signalAll()方法时候,线程会重新获得锁并继续执行。或者当线程被中断时,也能跳出等待。这和Object.wait()方法很相似。
  • awaitUninterruptibly()await()方法类似,但它不会再等待过程中响应中断。
  • singal() 用于唤醒一个等待队列中的线程。singalAll()是唤醒所有等待线程。
public class ConditionExample implements Runnable{
    public static ReentrantLock lock = new ReentrantLock();
    public static Condition condition = lock.newCondition();


    @Override
    public void run() {

        try {
            lock.lock();
            System.out.println(Thread.currentThread().getName() + " 获取到锁...");
            //等待
            condition.await();
            System.out.println(Thread.currentThread().getName() + " 执行完成");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            //释放锁
            lock.unlock();
            System.out.println(Thread.currentThread().getName() + " 释放锁");
        }


    }

    public static void main(String[] a) throws InterruptedException {
        ConditionExample re = new ConditionExample();
        Thread t1 = new Thread(re,"1 号线程 ");
        t1.start();
        //主线程sleep,1号线程会一直等待.直到获取到1号线程的锁资源,并将其唤醒.
        Thread.sleep(2000);
        //获得锁
        lock.lock();
        //唤醒前必须获得当前资源对象的锁
        condition.signal();
        //释放锁
        lock.unlock();

    }
}

ReadWriteLock读写锁

ReadWriteLock是JDK5中提供的读写分离锁。读写分离锁可以有效地帮助减少锁竞争,以提升系统性能。用锁分离的机制来提升性能非常容易理解,比如线程A1、A2、A3进行写操作,B1、B2、B3进行读操作,如果使用重入锁或者内部锁,则理论上说所有读之间、读与写之间、写和写之间都是串行操作。当B1进行读取时,B2、B3则需要等待锁。由于读操作并不对数据的完整性造成破坏,这种等待显然是不合理的。因此,读写锁就有了发挥功能的余地。

在这种情况下,读写锁运行多个线程同时读。但是考虑到数据完整性,写写操作和读写操作间依然是需要互相等待和持有锁的。总的来说,读写锁的访问约束如下表。

非阻塞 阻塞
阻塞 阻塞

如果在系统中,读的次数远远大于写的操作,读写锁就可以发挥最大的功效,提升系统的性能。

栗子:

public class ReadWriteLockExample {

    //创建普通重入锁
    private static Lock lock = new ReentrantLock();

    //创建读写分离锁
    private static ReentrantReadWriteLock rwlock = new ReentrantReadWriteLock();

    //创建读锁
    private static Lock readLock = rwlock.readLock();

    //创建写锁
    private static Lock writeLock = rwlock.writeLock();

    private  int value;

    public Object HandleRead(Lock lock) throws InterruptedException {
        try {
            //上锁
            lock.lock();
            //模拟处理业务
            Thread.sleep(1000);
            System.out.println(Thread.currentThread().getName() + " Read...");
            return value;
        } finally {
            //释放锁
            lock.unlock();
        }
    }

    public void HandleWrite(Lock lock,int index) throws InterruptedException {
        try {
            lock.lock();
            Thread.sleep(1000);
            value = index;
            System.out.println(Thread.currentThread().getName() + " Write...");
        }finally {
            lock.unlock();
        }
    }

    public static void main(String[] a ) throws InterruptedException {
        final ReadWriteLockExample rwle = new ReadWriteLockExample();

        //创建读方法
        Runnable readR = new Runnable() {
            @Override
            public void run() {
                try {
                    //rwle.HandleRead(lock); //普通锁
                    rwle.HandleRead(readLock);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };

        //创建写方法
        Runnable writeR = new Runnable() {
            @Override
            public void run() {
                try {
                    //rwle.HandleWrite(lock,new Random().nextInt()); //普通锁
                    rwle.HandleWrite(writeLock,new Random().nextInt());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };

        //18次读
        for (int i=0;i<18;i++){
            Thread s = new Thread(readR);
            s.start();
        }
        //2次写
        for (int i=18;i<20;i++){
            Thread s = new Thread(writeR);
            s.start();
        }

        /**
         * 结论:
         *
         * 用普通锁运行,大约执行20秒左右
         *
         * 用读写分离锁,大约执行3秒左右
         *
         */

    }

}

在读锁和写锁之间的交互可以采用多种实现方式。ReadWriteLock中的一些可选实现包括:

  • 释放优先:当一个写入操作释放写入锁时,并且队列中同时存在读线程和写线程,那么应该优先选择哪一个线程。
  • 读线程插队:如果锁是由读线程持有,但是写线程还在等待,是否允许新到的读线程获得访问权,还是应在写线程后面等待?若允许的话可以提高并发性但是可能造成写线程的饥饿。
  • 重入性:读取锁和写入锁是否可重入。
  • 降级和升级:若一个线程持有写锁可否在继续持有写锁的状态下获取读锁?这可能会使写锁“降级”为读锁。读锁是否优先于其它正在等待的读线程和写线程而升级为一个写锁?在大多数读写锁实现中不支持“升级”,因为这样容易死锁(两个读线程试图同时升级为写锁,那么二者都不会释放写锁)。

闭锁

闭锁是一种同步工具类,可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程能通过,当到达结束状态时,这扇门会打开并允许所有的线程通过。当闭锁到达结束状态后,将不会再改变状态,因此这扇门将永远保持打开状态。闭锁可以用来确保某些活动直到其他活动都完成才继续执行,例如:

  • 确保某个计算在其需要的所有资源都被初始化后才继续执行。二元闭锁(包括两个状态)可以用来表示“资源R已经被初始化”,而所有需要R的操作都必须先在这个比锁上等待。
  • 确保某个服务在其依赖的所有其他服务都已经启动之后才启动。每个服务都有一个相关的二元闭锁。当启动服务S时,将首先在S依赖的其他服务的闭锁上等待,在所有依赖的服务都启动后会释放闭锁S,这样其他依赖S的服务才能继续执行。
  1. 等待直到某个操作的所有参与者(例如,在多玩家游戏中的所有玩家)都就绪再继续执行。在这种情况中,当所有的玩家都执行就绪时,闭锁将到达结束状态。

CountDownLatch

CountDownLatch 就是一种灵活的闭锁实现,可以在上述的各种情况中使用,它可以使一个或多个线程等待一组时间发生CountDown在英文中意为倒计时,Latch为门闩。闭锁的状态包括一个计数器,该计数器被初始化为一个正数,表示需要等待的事情数量。countDown方法递减计数器,表示有一个事件已经发生了,而await方法等待计数器达到零,这表示所有需要等待的事情都已经发生。如果计数器的值是非零,那么await会一直阻塞直到计数器为零,或者等待中的线程中断,或者等待超时。因此,这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。

CountDownLatch的构造函数接收一个整数作为参数,即当前这个计数器的技术个数。

public CountDownLatch(int count)

下面这个简单的示例,演示了CountDownLatch的使用。

public class CountDownLatchExample implements Runnable{

    static final CountDownLatch cdl = new CountDownLatch(10);
    static final CountDownLatchExample cdle = new CountDownLatchExample();

    @Override
    public void run() {
        try {
            Thread.sleep(new Random().nextInt(10) * 1000);
            System.out.println(Thread.currentThread().getName() + " 部件检查完毕...");
            //一个线程完成工作,倒计时器减1
            cdl.countDown();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] a) throws InterruptedException {
        ExecutorService exec = Executors.newFixedThreadPool(10);
        for (int i=0;i<10;i++){
            exec.submit(cdle);
        }
        //等待所有线程完成,主线程才继续执行
        cdl.await();
        System.out.println(Thread.currentThread().getName() + " 所有检查完成,上跑道起飞...");
        //关闭线程池
        exec.shutdown();
    }
}

FutureTask

FutureTask也可以用做闭锁。FutureTask表示的计算是通过Callable来实现的,相当于一种可生成结果的Runnable,并且可以处于以下3种状态:

  • 等待运行(Waiting to run)
  • 正在运行(Running)
  • 运行完成(Completed)

Future.get的行为取决于任务的状态。如果任务已经完成,那么get会立即返回结果,否则get将阻塞直到任务进入完成状态,然后返回结果或者抛出异常。FutureTask将计算结果从执行的计算的线程传递到获取这个结果的线程,而FutureTask的规划确保了这种传递过程能够实现结果的安全发布。

FutureTask在ExeCutor中表示异步任务,此外还可以用来表示一些时间较长的计算,这些计算可以在使用计算结果之前启动。

信号量

技术信号量(Counting Semaphore)用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。计数信号量还可以用来实现某种资源池,或者对容器施加边界。

信号量为多线程协作提供了更为强大的控制方法。广义上说,信号量是对锁的扩展。无论是内部锁synchronized还是重入锁ReentrantLock,一次都只允许一个线程访问一个资源,而信号量却可以指定多个线程,同时访问某一个资源。信号量主要提供了以下构造函数:

public Semaphore(int permits)
public Semaphore(int permits,boolean fair) //第二个参数可以指定是否公平

在构造信号量对象时,必须要指定信号量的准入数,即同时能申请多少个许可。当每个线程每次只申请一个许可时,这就相当于指定了同时有多少个线程可以访问某一个资源。信号量的主要逻辑方法有:

public void acquire() //尝试获得一个准入的许可。若无法获得,则线程会等待,直到有线程释放一个许可或者当前线程被中断
public void acquireUninterruptibly()//和acquire()类似,但是不响应中断
public boolean tryAcquire()//尝试获得一个许可,成功true失败fasle,不会等待,立刻返回
public boolean tryAcquire(long timeout,TimeUnit unit)
public void release()//线程访问资源结束后,释放一个许可,以使其他等待许可的线程进行资源访问

栗子:

public class SemaphoreExample implements Runnable {

    //指定信号量,同时可以有5个线程访问资源
    public static final Semaphore s = new Semaphore(5);

    @Override
    public void run() {

        try {
            //申请信号量,也可以直接使用 s.acquire();
            if (s.tryAcquire(1500, TimeUnit.SECONDS)) {
                //模拟耗时操作
                Thread.sleep(2000);
                System.out.println(Thread.currentThread().getName() + " 完成了任务..");
                //离开时必须释放信号量,不然会导致信号量泄露——申请了但没有释放
                s.release();
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] a) throws InterruptedException {
       //申请20个线程
        ExecutorService exec = Executors.newFixedThreadPool(20);
        final SemaphoreExample re = new SemaphoreExample();
        for (int i=0;i<20;i++){
            exec.submit(re);
        }
        exec.shutdown();
    }
}

Semaphore中管理者一组虚拟的许可(permit),许可的初始数量可通过构造函数来指定。在执行操作时可以先获得许可(只要还有剩余的许可),并在使用以后释放许可。如果没有许可,那么acquire将阻塞直到有许可(或者被中断或者操作超时)。release方法将返回一个许可给信号量。计算信号量的一种简化形式是二值信号量,即初始值为1的Semaphore。二值信号量可以用做互斥体(mutex),并具备不可重入的加锁语义:谁拥有这个唯一的许可,谁就拥有了互斥锁。

同样,我们也可以使用Semaphore将任何一种容器变成有界阻塞容器。

CyclicBarrier

和之前的CountDownLatch类似,它(循环栅栏)也可以实现线程间的技术等待,但它的功能比CountDownLatch更加复杂强大。它能阻塞一组线程直到某个事件发生。因此,栅栏可以用于实现一些协议,例如“开会一定要在xx地方集合,等其他人到了再讨论下一步要做的事情”。

CyclicBarrier的使用场景也很丰富。比如,司令下达命令,要求10个士兵一起去完成一项任务。这时,就会要求10个士兵先集合报道,接着,一起雄赳赳气昂昂地执行任务。当10个士兵把自己手头的任务都执行完成了,那么司令才能对外宣布,任务完成!

下面的栗子使用CyclicBarrier演示了上述司机命令士兵完成任务的场景。

public class CyclicBarrierExample {

    public static class Soldier implements Runnable{

        private String name;
        private CyclicBarrier cyclicBarrier;

        public Soldier(String name, CyclicBarrier cyclicBarrier) {
            this.name = name;
            this.cyclicBarrier = cyclicBarrier;
        }

        @Override
        public void run() {
            try {
                System.out.println(name + " 来报道..");
                //等待所有士兵到齐
                cyclicBarrier.await();
                doWork();
                //等待所有士兵完成任务
                cyclicBarrier.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (BrokenBarrierException e) {
                e.printStackTrace();
            }
        }

        void doWork(){
            try {
                Thread.sleep(new Random().nextInt(10) * 1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(name + " 任务已完成..");
        }
    }

    public static class doOrder implements Runnable{

        boolean flag;
        int n;

        public doOrder(boolean flag, int n) {
            this.flag = flag;
            this.n = n;
        }

        @Override
        public void run() {
            if (flag){
                System.out.println("司令 : 士兵 " + n +"个 任务完成");
            }else {
                System.out.println("司令 : 士兵 " + n +"个 集合完毕");
                //执行完后 改变完成标记.当下一次调用doOrder时,可以进入if
                flag = true;
            }
        }
    }

    public static void main(String[] a){
        final int n = 10;
        //是否完成了任务
        boolean flag = false;

        //创建10个士兵线程
        Thread[] allSoldier = new Thread[n];
        //创建CyclicBarrier实例
        //这里的意思是,等待10个线程都执行完,就执行doOrder()方法
        CyclicBarrier c = new CyclicBarrier(n, new doOrder(flag,n));
        for (int i=0;i<n;i++){
            //System.out.println("士兵" + i + " 报道");
            //装配士兵线程
            allSoldier[i] = new Thread(new Soldier("士兵" + i,c));
            /**
             * 开启士兵线程,但是执行到第一个cyclicBarrier.await()栅栏时,
             * 要等待,等到10个士兵线程都到这里等着,等到执行完doOrder()方法后,完成第一次计数.
             *
             * 这样才能继续执行下一个方法doWork(),而doWork()完成后,又需要第二次等待,
             * 等待全部士兵线程都到等待队列后,再次调用doOrder()方法.完成第二次计数.
             * 而这个方法中,每个线程的flag都已经改变,利用flag,完成任务.
             *
             */
            allSoldier[i].start();

            /*
                执行结果:

                士兵0 来报道..
                士兵1 来报道..
                ......
                士兵8 来报道..
                士兵9 来报道..
                司令 : 士兵 10个 集合完毕
                士兵2 任务已完成..
                士兵8 任务已完成..
                ......
                士兵9 任务已完成..
                士兵4 任务已完成..
                司令 : 士兵 10个 任务完成
            */

        }
    }

}

优化锁

一般我们对于锁的优化有以下几个大致方向:

  • 减少锁的持有时间
  • 减少锁的请求频率
  • 使用带有协调机制的独占锁,这些机制允许更高的并发性

锁分段技术

在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。例如,在ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。假设散列函数具有合理的分布性,并且关键字能够均匀分布,那么这大约能把对于锁的请求减少到原来的1/16,正是这项技术使得ConcurrentHashMap能够支持多达16个并发的写入器。(要使得拥有大量处理器的系统在高访问量的情况下实现更高的并发性,还可以进一步增加锁的数量,但仅当你能证明并发写入线程的竞争足够激烈并需要突破这个限制时,才能将锁分段的数量超过默认的16个。)

另一个典型的案例就是LinkedBlockingQueue的实现。
take()和put()方法虽然都对队列进行了修改操作,但由于是链表,因此,两个操作分别作用于队列的前端和末尾,理论上两者并不冲突。使用独占锁,则要求在进行take和put操作时获取当前队列的独占锁,那么take和put就不可能真正的并发,他们会彼此等待对方释放锁。在JDK的实现中,取而代之的是两把不同的锁,分离了take和put操作.削弱了竞争的可能性.实现类取数据和写数据的分离,实现了真正意义上成为并发操作。

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

避免热点域

锁分解和锁分段技术都能提高可伸缩性,因为它们都能使不同的线程在不同的数据(或者同一个数据的不同部分)上操作,而不会相互干扰。如果程序采用锁分段或分解技术,那么一定要表现出在锁上的竞争频率高于在锁保护的数据上发生竞争的频率。如果一个锁保护两个独立变量X和Y,并且线程A想要访问X,而线程B想要访问Y(这类似于在ServerStatus中,一个线程调用addUser,而另一个线程调用addQuery),那么这两个线程不会在任何数据上发生竞争,即使它们会在同一个锁上发生竞争。

当每个操作都请求多个变量时,锁的粒度将很难降低。这是在性能与可伸缩性之间相互制衡的另一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引入一些”热点域“,而这些热点域往往会限制可伸缩性。

当实现HashMap时,你需要考虑如何在size方法中计算Map中的元素数量。最简单的方法就是,在每次调用时都统计一次元素的数量。一种常见的优化措施是,在插入和移除元素时更新一个计数器,虽然这在put和remove等方法中略微增加了一些开销,以确保计数器是最新的值,但这把size方法的开销从O(n)降低到O(1)。

在单线程或者采用完全同步的实现中,使用一个独立的计算器能很好地提高类似size和isEmpty这些方法的执行速度,但却导致更难以提升实现的可伸缩性,因为每个修改map的操作都需要更新这个共享的计数器。即使使用锁分段技术来实现散列链,那么在对计数器的访问进行同步时,也会重新导致在使用独占锁时存在的可伸缩性问题。一个看似性能优化的措施——缓存size操作的结果,已经变成了一个可伸缩性问题。在这种情况下,计数器也被称为热点域,因为每个导致元素数量发生变化的操作都需要访问它。
为了避免这个问题,ConcurrentHashMap中的size将对每个分段进行枚举并将每个分段中的元素数量相加,而不是维护一个全局计数。为了避免枚举每个元素,ConcurrentHashMap为每个分段都维护一个独立的计数,并通过每个分段的锁来维护这个值。

一些替代独占锁的方法

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

ReadWriteLock实现了一种在多个读取操作以及单个写入操作情况下的加锁规则:如果多个读取操作都不会修改共享资源,那么这些读取操作可以同时访问该共享资源,但在执行写入操作时必须以独占方式来获取锁。对于读取操作占多数的数据结构,ReadWriteLock能够提供比独占锁更高的并发性。而对于只读的数据结构,其中包含的不变性可以完全不需要加锁操作。

原子变量提供了一种方式来降低更新“热点域”时的开销,例如竞态计数器、序列发生器、或者对链表数据结构中头节点的引用。原子变量类提供了在整数或者对象引用上的细粒度原子操作(因此可伸缩性更高),并使用了现代处理器中提供的底层并发原语(例如比较并交换)。如果在类中只包含少量的热点域,并且这些域不会与其他变量参与到不变性条件中,那么用原子变量来替代他们能提高可伸缩性。(通过减少算法中的热点域,可以提高可伸缩性——虽然原子变量能降低热点域的更新开销,但并不能完全消除。)

来自JVM的锁优化

锁粗化

如果对一个锁不停地进行请求,同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能优化.
虚拟机在遇到需要一连串对同一把锁不断进行请求和释放操作的情况时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步次数,这就是锁的粗化。

锁偏向

偏向锁是一种针对加锁操作的优化手段,他的核心思想是:如果一个线程获得了锁,那么锁就进行偏向模式.当这个线程再次请求锁时,无需再做任何同步操作.这样就节省了大量操作锁的动作,从而提高程序性能.

因此,对于几乎没有锁竞争的场合,偏向锁有比较好的优化效果.因为极有可能连续多次是同一个线程请求相同的锁.而对于锁竞争激烈的程序,其效果不佳.

使用Java虚拟机参数:-XX:+UseBiasedLocking 可以开启偏向锁.

轻量级锁

如果偏向锁失败,虚拟机并不会立即挂起线程.它还会使用一种称为轻量级的锁的优化手段.轻量级锁只是简单的将对象头部作为指针,指向持有锁的线程堆栈内部,来判断一个线程是否持有对象锁.如果线程获得轻量锁成功,则可以顺利进入临界区.如果失败,则表示其他线程争抢到了锁,那么当前线程的锁请求就会膨胀为重量级锁.

自旋锁

锁膨胀后,虚拟机为了避免线程真实的在操作系统层面挂起,虚拟机还做了最后的努力就是自旋锁.如果一个线程暂时无法获得索,有可能在几个CPU时钟周期后就可以得到锁,
那么简单粗暴的挂起线程可能是得不偿失的操作.虚拟机会假设在很短时间内线程是可以获得锁的,所以会让线程自己空循环(这便是自旋的含义),如果尝试若干次后,可以得到锁,那么久可以顺利进入临界区,
如果还得不到,才会真实地讲线程在操作系统层面挂起.

锁消除

锁消除是一种更彻底的锁优化,Java虚拟机在JIT编译时,通过对运用上下文的扫描,去除不可能存在的共享资源竞争锁,节省毫无意义的资源开销.

我们可能会问:如果不可能存在竞争,为什么程序员还要加上锁呢?

在Java软件开发过程中,我们必然会用上一些JDK的内置API,比如StringBuffer、Vector等。你在使用这些类的时候,也许根本不会考虑这些对象到底内部是如何实现的。比如,你很有可能在一个不可能存在并发竞争的场合使用Vector。而周所众知,Vector内部使用了synchronized请求锁,如下代码:

public String [] createString(){
  Vector<String> v = new Vector<String>();
  for (int i =0;i<100;i++){
    v.add(Integer.toString(i));
  }
  return v.toArray(new String[]{});
}

上述代码中的Vector,由于变量v只在createString()函数中使用,因此,它只是一个单纯的局部变量。局部变量是在线程栈上分配的,属于线程私有的数据,因此不可能被其他线程访问。所以,在这种情况下,Vector内部所有加锁同步都是没有必要的。如果虚拟机检测到这种情况,就会将这些无用的锁操作去除。

锁消除设计的一项关键技术是逃逸分析,就是观察某个变量是否会跳出某个作用域(比如对Vector的一些操作).在本例中,变量v显然没有逃出createString()函数之外。以次为基础,虚拟机才可以大胆将v内部逃逸出当前函数,也就是说v有可能被其他线程访问。如果是这样,虚拟机就不能消除v中的锁操作。

逃逸分析必须在-server模式下进行,可以使用-XX:+DoEscapeAnalysis参数打开逃逸分析。使用-XX:+EliminateLocks参数可以打开锁消除。

阅读 2.4k

推荐阅读
泊浮说
用户专栏

作者是个热爱分享交流的人,所以有了这个专栏。你的点赞是我最大的更新动力。

56 人关注
45 篇文章
专栏主页
目录