• 编写高质量代码:改善Java程序的151个建议(第8章:多线程和并发___建议126~128)


    建议126:适时选择不同的线程池来实现

      Java的线程池实现从根本上来说只有两个:ThreadPoolExecutor类和ScheduledThreadPoolExecutor类,这两个类还是父子关系,但是Java为了简化并行计算,还提供了一个Exceutors的静态类,它可以直接生成多种不同的线程池执行器,比如单线程执行器、带缓冲功能的执行器等,但归根结底还是使用ThreadPoolExecutor类或ScheduledThreadPoolExecutor类的封装类。

      为了理解这些执行器,我们首先来看看ThreadPoolExecutor类,其中它复杂的构造函数可以很好的理解线程池的作用,代码如下:  

    public class ThreadPoolExecutor extends AbstractExecutorService {
        // 最完整的构造函数
        public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize,
                long keepAliveTime, TimeUnit unit,
                BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory,
                RejectedExecutionHandler handler) {
            // 检验输入条件
            if (corePoolSize < 0 || maximumPoolSize <= 0
                    || maximumPoolSize < corePoolSize || keepAliveTime < 0)
                throw new IllegalArgumentException();
            // 检验运行环境
            if (workQueue == null || threadFactory == null || handler == null)
                throw new NullPointerException();
            this.corePoolSize = corePoolSize;
            this.maximumPoolSize = maximumPoolSize;
            this.workQueue = workQueue;
            this.keepAliveTime = unit.toNanos(keepAliveTime);
            this.threadFactory = threadFactory;
            this.handler = handler;
        }
    }

      这是ThreadPoolExecutor最完整的构造函数,其他的构造函数都是引用该构造函数实现的,我们逐步来解释这些参数的含义。

    1. corePoolSize:最小线程数。线程启动后,在池中保持线程的最小数量。需要说明的是线程数量是逐步到达corePoolSize值的,例如corePoolSize被设置为10,而任务数量为5,则线程池中最多会启动5个线程,而不是一次性的启动10个线程。
    2. maximumPoolSize:最大线程数量。这是池中最大能容纳的最大线程数量,如果超出,则使用RejectedExecutionHandler 拒绝策略处理。
    3. keepAliveTime:线程最大生命周期。这里的生命周期有两个约束条件,一是该参数针对的是超过corePoolSize数量的线程。二是处于非运行状态的线程。这么说吧,如果corePoolSize为10,maximumPoolSize为20,此时线程池中有15个线程正在运行,一段时间后,其中有3个线程处于等待状态的时间超过了keepAliveTime指定的时间,则结束这3个线程,此时线程池中还有12个线程正在运行。
    4. unit:时间单位。这是keepAliveTime的时间单位,可以是纳秒、毫秒、秒、分等选项。
    5. workQuene:任务队列。当线程池中的线程都处于运行状态,而此时任务数量继续增加,则需要一个容器来容纳这些任务,这就是任务队列。
    6. threadFactory:线程工厂。定义如何启动一个线程,可以设置线程名称,并且可以确认是否是后台线程等。
    7. handler:拒绝任务处理器。由于超出线程数量和队列容量而对继续增加的任务进行处理的程序。

      线程池的管理是这样一个过程:首先创建线程池,然后根据任务的数量逐步将线程增大到corePoolSize数量,如果此时仍有任务增加,则放置到workQuene中,直到workQuene爆满为止,然后继续增加池中的数量(增强处理能力),最终达到maximumPoolSize,那如果此时还有任务增加进来呢?这就需要handler处理了,或者丢弃任务,或者拒绝新任务,或者挤占已有任务等。

      在任务队列和线程池都饱和的情况下,一但有线程处于等待(任务处理完毕,没有新任务增加)状态的时间超过keepAliveTime,则该线程终止,也就说池中的线程数量会逐渐降低,直至为corePoolSize数量为止。

      我们可以把线程池想象为这样一个场景:在一个生产线上,车间规定是可以有corePoolSize数量的工人,但是生产线刚建立时,工作不多,不需要那么多的人。随着工作数量的增加,工人数量也逐渐增加,直至增加到corePoolSize数量为止。此时还有任务增加怎么办呢?

      好办,任务排队,corePoolSize数量的工人不停歇的处理任务,新增加的任务按照一定的规则存放在仓库中(也就是我们的workQuene中),一旦任务增加的速度超过了工人处理的能力,也就是说仓库爆满时,车间就会继续招聘工人(也就是扩大线程数),直至工人数量到达maximumPoolSize为止,那如果所有的maximumPoolSize工人都在处理任务时,而且仓库也是饱和状态,新增任务该怎么处理呢?这就会扔一个叫handler的专门机构去处理了,它要么丢弃这些新增的任务,要么无视,要么替换掉别的任务。

      过了一段时间后,任务的数量逐渐减少,导致一部分工人处于待工状态,为了减少开支(Java是为了减少系统的资源消耗),于是开始辞退工人,直至保持corePoolSize数量的工人为止,此时即使没有工作,也不再辞退工人(池中的线程数量不再减少),这也是保证以后再有任务时能够快速的处理。

      明白了线程池的概念,我们再来看看Executors提供的几个线程创建线程池的便捷方法:

    • newSingleThreadExecutor:单线程池。顾名思义就是一个池中只有一个线程在运行,该线程永不超时,而且由于是一个线程,当有多个任务需要处理时,会将它们放置到一个无界阻塞队列中逐个处理,它的实现代码如下:  
     public static ExecutorService newSingleThreadExecutor() {
            return new FinalizableDelegatedExecutorService
                (new ThreadPoolExecutor(1, 1,
                                        0L, TimeUnit.MILLISECONDS,
                                        new LinkedBlockingQueue<Runnable>()));
        }

      它的使用方法也很简单,下面是简单的示例:

    public static void main(String[] args) throws ExecutionException,
                InterruptedException {
            // 创建单线程执行器
            ExecutorService es = Executors.newSingleThreadExecutor();
            // 执行一个任务
            Future<String> future = es.submit(new Callable<String>() {
                @Override
                public String call() throws Exception {
                    return "";
                }
            });
            // 获得任务执行后的返回值
            System.out.println("返回值:" + future.get());
            // 关闭执行器
            es.shutdown();
        }
    • newCachedThreadPool:缓冲功能的线程。建立了一个线程池,而且线程数量是没有限制的(当然,不能超过Integer的最大值),新增一个任务即有一个线程处理,或者复用之前空闲的线程,或者重亲启动一个线程,但是一旦一个线程在60秒内一直处于等待状态时(也就是一分钟无事可做),则会被终止,其源码如下: 
      public static ExecutorService newCachedThreadPool() {
            return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                          60L, TimeUnit.SECONDS,
                                          new SynchronousQueue<Runnable>());
        }

      这里需要说明的是,任务队列使用了同步阻塞队列,这意味着向队列中加入一个元素,即可唤醒一个线程(新创建的线程或复用空闲线程来处理),这种队列已经没有队列深度的概念了.

    • newFixedThreadPool:固定线程数量的线程池。 在初始化时已经决定了线程的最大数量,若任务添加的能力超出了线程的处理能力,则建立阻塞队列容纳多余的任务,其源码如下: 
    public static ExecutorService newFixedThreadPool(int nThreads) {
            return new ThreadPoolExecutor(nThreads, nThreads,
                                          0L, TimeUnit.MILLISECONDS,
                                          new LinkedBlockingQueue<Runnable>());
        }

      上面返回的是一个ThreadPoolExecutor,它的corePoolSize和maximumPoolSize是相等的,也就是说,最大线程数量为nThreads。如果任务增长的速度非常快,超过了LinkedBlockingQuene的最大容量(Integer的最大值),那此时会如何处理呢?会按照ThreadPoolExecutor默认的拒绝策略(默认是DiscardPolicy,直接丢弃)来处理。

      以上三种线程池执行器都是ThreadPoolExecutor的简化版,目的是帮助开发人员屏蔽过得线程细节,简化多线程开发。当需要运行异步任务时,可以直接通过Executors获得一个线程池,然后运行任务,不需要关注ThreadPoolExecutor的一系列参数是什么含义。当然,有时候这三个线程不能满足要求,此时则可以直接操作ThreadPoolExecutor来实现复杂的多线程计算。可以这样比喻,newSingleThreadExecutor、newCachedThreadPool、newFixedThreadPool是线程池的简化版,而ThreadPoolExecutor则是旗舰版___简化版容易操作,需要了解的知识相对少些,方便使用,而旗舰版功能齐全,适用面广,难以驾驭。

    建议127:Lock与synchronized是不一样的

      很多编码者都会说,Lock类和synchronized关键字用在代码块的并发性和内存上时语义是一样的,都是保持代码块同时只有一个线程执行权。这样的说法只说对了一半,我们以一个任务提交给多个线程为例,来看看使用显示锁(Lock类)和内部锁(synchronized关键字)有什么不同,首先定义一个任务:

    class Task {
        public void doSomething() {
            try {
                // 每个线程等待2秒钟,注意此时线程的状态转变为Warning状态
                Thread.sleep(2000);
            } catch (Exception e) {
                // 异常处理
            }
            StringBuffer sb = new StringBuffer();
            // 线程名称
            sb.append("线程名称:" + Thread.currentThread().getName());
            // 运行时间戳
            sb.append(",执行时间: " + Calendar.getInstance().get(Calendar.SECOND) + "s");
            System.out.println(sb);
        }
    }

      该类模拟了一个执行时间比较长的计算,注意这里是模拟方式,在使用sleep方法时线程的状态会从运行状态转变为等待状态。该任务具备多线程能力时必须实现Runnable接口,我们分别建立两种不同的实现机制,先看显示锁实现:  

    class TaskWithLock extends Task implements Runnable {
        // 声明显示锁
        private final Lock lock = new ReentrantLock();
        @Override
        public void run() {
            try {
                // 开始锁定
                lock.lock();
                doSomething();
    
            } finally {
                // 释放锁
                lock.unlock();
            }
        }
    }

      这里有一点需要说明,显示锁的锁定和释放必须放在一个try......finally块中,这是为了确保即使出现异常也能正常释放锁,保证其它线程能顺利执行。

      内部锁的处理也非常简单,代码如下: 

    //内部锁任务
    class TaskWithSync extends Task implements Runnable{
        @Override
        public void run() {
            //内部锁
            synchronized("A"){
                doSomething();
            }
        }
        
    }

      这两个任务看着非常相似,应该能够产生相同的结果吧?我们建立一个模拟场景,保证同时有三个线程在运行,代码如下: 

    public class Client127 {
        public static void main(String[] args) throws Exception {
            // 运行显示任务
            runTasks(TaskWithLock.class);
            // 运行内部锁任务
            runTasks(TaskWithSync.class);
        }
    
        public static void runTasks(Class<? extends Runnable> clz) throws Exception {
            ExecutorService es = Executors.newCachedThreadPool();
            System.out.println("***开始执行 " + clz.getSimpleName() + " 任务***");
            // 启动3个线程
            for (int i = 0; i < 3; i++) {
                es.submit(clz.newInstance());
            }
            // 等待足够长的时间,然后关闭执行器
            TimeUnit.SECONDS.sleep(10);
            System.out.println("---" + clz.getSimpleName() + "  任务执行完毕---
    ");
            // 关闭执行器
            es.shutdown();
        }
    }

      按照一般的理解,Lock和synchronized的处理方式是相同的,输出应该没有差别,但是很遗憾的是,输出差别其实很大。输出如下:

            ***开始执行 TaskWithLock 任务***
              线程名称:pool-1-thread-2,执行时间: 55s
              线程名称:pool-1-thread-1,执行时间: 55s
              线程名称:pool-1-thread-3,执行时间: 55s
            ---TaskWithLock  任务执行完毕---

            ***开始执行 TaskWithSync 任务***
              线程名称:pool-2-thread-1,执行时间: 5s
              线程名称:pool-2-thread-3,执行时间: 7s
              线程名称:pool-2-thread-2,执行时间: 9s
            ---TaskWithSync  任务执行完毕---

       注意看运行的时间戳,显示锁是同时运行的,很显然pool-1-thread-1线程执行到sleep时,其它两个线程也会运行到这里,一起等待,然后一起输出,这还具有线程互斥的概念吗?

      而内部锁的输出则是我们预期的结果,pool-2-thread-1线程在运行时其它线程处于等待状态,pool-2-threda-1执行完毕后,JVM从等待线程池中随机获的一个线程pool-2-thread-3执行,最后执行pool-2-thread-2,这正是我们希望的。

      现在问题来了:Lock锁为什么不出现互斥情况呢?

      这是因为对于同步资源来说(示例中的代码块)显示锁是对象级别的锁,而内部锁是类级别的锁,也就说说Lock锁是跟随对象的,synchronized锁是跟随类的,更简单的说把Lock定义为多线程类的私有属性是起不到资源互斥作用的,除非是把Lock定义为所有线程的共享变量。都说代码是最好的解释语言,我们来看一个Lock锁资源的代码:  

    public static void main(String[] args) {
            // 多个线程共享锁
            final Lock lock = new ReentrantLock();
            // 启动三个线程
            for (int i = 0; i < 3; i++) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            lock.lock();
                            // 休眠2秒钟
                            Thread.sleep(2000);
                            System.out.println(Thread.currentThread().getName());
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        } finally {
                            lock.unlock();
                        }
                    }
                }).start();
            }
        }

      执行时,会发现线程名称Thread-0、Thread-1、Thread-2会逐渐输出,也就是一个线程在执行时,其它线程就处于等待状态。注意,这里三个线程运行的实例对象是同一个类。

      除了这一点不同之外,显示锁和内部锁还有什么区别呢?还有以下4点不同:

    1. Lock支持更细精度的锁控制:假设读写锁分离,写操作时不允许有读写操作存在,而读操作时读写可以并发执行,这一点内部锁就很难实现。显示锁的示例代码如下:  
    class Foo {
        // 可重入的读写锁
        private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
        // 读锁
        private final Lock r = rwl.readLock();
        // 写锁
        private final Lock w = rwl.writeLock();
    
        // 多操作,可并发执行
        public void read() {
            try {
                r.lock();
                Thread.sleep(1000);
                System.out.println("read......");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                r.unlock();
            }
        }
    
        // 写操作,同时只允许一个写操作
        public void write() {
            try {
                w.lock();
                Thread.sleep(1000);
                System.out.println("write.....");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                w.unlock();
            }
        }
    
    }

      可以编写一个Runnable实现类,把Foo类作为资源进行调用(注意多线程是共享这个资源的),然后就会发现这样的现象:读写锁允许同时有多个读操作但只允许一个写操作,也就是当有一个写线程在执行时,所有的读线程都会阻塞,直到写线程释放锁资源为止,而读锁则可以有多个线程同时执行。

     2.Lock锁是无阻塞锁,synchronized是阻塞锁

        当线程A持有锁时,线程B也期望获得锁,此时,如果程序中使用的显示锁,则B线程为等待状态(在通常的描述中,也认为此线程被阻塞了),若使用的是内部锁则为阻塞状态。

     3.Lock可实现公平锁,synchronized只能是非公平锁

      什么叫非公平锁呢?当一个线程A持有锁,而线程B、C处于阻塞(或等待)状态时,若线程A释放锁,JVM将从线程B、C中随机选择一个持有锁并使其获得执行权,这叫非公平锁(因为它抛弃了先来后到的顺序);若JVM选择了等待时间最长的一个线程持有锁,则为公平锁(保证每个线程的等待时间均衡)。需要注意的是,即使是公平锁,JVM也无法准确做到" 公平 ",在程序中不能以此作为精确计算。

      显示锁默认是非公平锁,但可以在构造函数中加入参数为true来声明出公平锁,而synchronized实现的是非公平锁,他不能实现公平锁。

     4.Lock是代码级的,synchronized是JVM级的

        Lock是通过编码实现的,synchronized是在运行期由JVM释放的,相对来说synchronized的优化可能性高,毕竟是在最核心的部分支持的,Lock的优化需要用户自行考虑。

        显示锁和内部锁的功能各不相同,在性能上也稍有差别,但随着JDK的不断推进,相对来说,显示锁使用起来更加便利和强大,在实际开发中选择哪种类型的锁就需要根据实际情况考虑了:灵活、强大选择lock,快捷、安全选择synchronized.

    建议128:预防线程死锁

     线程死锁(DeadLock)是多线程编码中最头疼的问题,也是最难重现的问题,因为Java是单进程的多线程语言,一旦线程死锁,则很难通过外科手术的方法使其起死回生,很多时候只有借助外部进程重启应用才能解决问题,我们看看下面的多线程代码是否会产生死锁:

    class Foo implements Runnable {
        @Override
        public void run() {
        fun(10);
        }
        // 递归方法
        public synchronized void fun(int i) {
            if (--i > 0) {
                for (int j = 0; j < i; j++) {
                    System.out.print("*");
                }
                System.out.println(i);
                fun(i);
            }
        }
    }

      注意fun方法是一个递归函数,而且还加上了synchronized关键字,它保证同时只有一个线程能够执行,想想synchronized关键字的作用:当一个带有synchronized关键字的方法在执行时,其他synchronized方法会被阻塞,因为线程持有该对象的锁,比如有这样的代码:  

    class Foo1 {
        public synchronized void m1() {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                // 异常处理
            }
            System.out.println("m1方法执行完毕");
        }
    
        public synchronized void m2() {
            System.out.println("m2方法执行完毕");
        }
    }

      相信大家都明白,先输出"m1执行完毕",然后再输出"m2"执行完毕,因为m1方法在执行时,线程t持有foo对象的锁,要想主线程获得m2方法的执行权限就必须等待m1方法执行完毕,也就是释放当前锁。明白了这个问题,我们思考一下上例中带有synchronized的递归方法是否能执行?会不会产生死锁?运行结果如下:

      *********9
      ********8
      *******7
      ******6
      *****5
      ****4
      ***3
      **2
      *1

     一个倒三角形,没有产生死锁,正常执行,这是为何呢?很奇怪,是吗?那是因为在运行时当前线程(Thread-0)获得了Foo对象的锁(synchronized虽然是标注在方法上的,但实际作用是整个对象),也就是该线程持有了foo对象的锁,所以它可以多次重如fun方法,也就是递归了。可以这样来思考该问题,一个包厢有N把钥匙,分别由N个海盗持有 (也就是我们Java的线程了),但是同一时间只能由一把钥匙打开宝箱,获取宝物,只有在上一个海盗关闭了包厢(释放锁)后,其它海盗才能继续打开获取宝物,这里还有一个规则:一旦一个海盗打开了宝箱,则该宝箱内的所有宝物对他来说都是开放的,即使是“ 宝箱中的宝箱”(即内箱)对他也是开放的。可以用如下代码来表示:  

    class Foo2 implements Runnable{
    
        @Override
        public void run() {
            method1();
        }
        public synchronized void method1(){
            method2();
        }
        public synchronized void method2(){
            //doSomething
        }
    }

      方法method1synchronized修饰的,方法method2也是synchronized修饰的,method1和method2方法重入完全是可行的,此种情况下会不会产生死锁。

      那什么情况下回产生死锁呢?看如下代码: 

    class A {
        public synchronized void a1(B b) {
            String name = Thread.currentThread().getName();
            System.out.println(name + "  进入A.a1()");
            try {
                // 休眠一秒 仍持有锁
                Thread.sleep(1000);
            } catch (Exception e) {
                // 异常处理
            }
            System.out.println(name + "  试图访问B.b2()");
            b.b2();
        }
    
        public synchronized void a2() {
            System.out.println("进入a.a2()");
        }
    }
    
    class B {
        public synchronized void b1(A a) {
            String name = Thread.currentThread().getName();
            System.out.println(name + "  进入B.b1()");
            try {
                // 休眠一秒 仍持有锁
                Thread.sleep(1000);
            } catch (Exception e) {
                // 异常处理
            }
            System.out.println(name + "  试图访问A.a2()");
            a.a2();
        }
    
        public synchronized void b2() {
            System.out.println("进入B.b2()");
        }
    }
    public static void main(String[] args) throws InterruptedException {
            final A a = new A();
            final B b = new B();
            // 线程A
            new Thread(new Runnable() {
                @Override
                public void run() {
                    a.a1(b);
                }
            }, "线程A").start();
            // 线程B
            new Thread(new Runnable() {
                @Override
                public void run() {
                    b.b1(a);
                }
            }, "线程B").start();
        }

      此段程序定义了两个资源A和B,然后在两个线程A、B中使用了该资源,由于两个资源之间交互操作,并且都是同步方法,因此在线程A休眠一秒钟后,它会试图访问资源B的b2方法。但是B线程持有该类的锁,并同时在等待A线程释放其锁资源,所以此时就出现了两个线程在互相等待释放资源的情况,也就是死锁了,运行结果如下:

       线程A  进入A.a1()
          线程B  进入B.b1()
      线程A  试图访问B.b2()
      线程B  试图访问A.a2()

      此种情况下,线程A和线程B会一直等下去,直到有外界干扰为止,比如终止一个线程,或者某一线程自行放弃资源的争抢,否则这两个线程就始终处于死锁状态了。我们知道达到线程死锁需要四个条件:

    1. 互斥条件:一个资源每次只能被一个线程使用
    2. 资源独占条件:一个线程因请求资源在未使用完之前,不能强行剥夺
    3. 不剥夺条件:线程已经获得的资源在未使用完之前,不能强行剥夺
    4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系

      只有满足了这些条件才能产生线程死锁,这也同时告诫我们如果要解决线程死锁问题,就必须从这四个条件入手,一般情况下可以按照以下两种方案解决:

      (1)、避免或减少资源共享

        一个资源被多个线程共享,若采用了同步机制,则产生死锁的可能性大,特别是在项目比较庞大的情况下,很难杜绝死锁,对此最好的解决办法就是减少资源共享。

        例如一个B/S结构的办公系统可以完全忽略资源共享,这是因为此类系统有三个特征:一是并发访问不会太高,二是读操作多于写操作,三是数据质量要求比较低,因此即使出现数据资源不同步的情况也不可能产生太大影响,完全可以不使用同步技术。但是如果是一个支付清算系统就必须慎重考虑资源同步问题了,因为此系统一是数据质量要求非常高(如果产生数据不同步的情况那可是重大生产事故),二是并发量大,不设置数据同步则会产生非常多的运算逻辑失效的情况,这会导致交易失败,产生大量的"脏数据",系统可靠性大大降低。

      (2)、使用自旋锁

        回到前面的例子,线程A在等待线程B释放资源,而线程B又在等待线程A释放资源,僵持不下,那如果线程B设置了超时时间是不是就可以解决该死锁问题了呢?比如线程B在等待2秒后还是无法获得资源,则自行终结该任务,代码如下:   

    public void b2() {
            try {
                // 立刻获得锁,或者2秒等待锁资源
                if (lock.tryLock(2, TimeUnit.SECONDS)) {
                    System.out.println("进入B.b2()");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }

      上面的代码中使用tryLock实现了自旋锁(Spin Lock),它跟互斥锁一样,如果一个执行单元要想访问被自旋锁保护的共享资源,则必须先得到锁,在访问完共享资源后,也必须释放锁。如果在获取自旋锁时,没有任何执行单元保持该锁,那么将立即得到锁;如果在获取自旋锁时已经有保持者,那么获取锁操作将"自旋" 在哪里,直到该自旋锁的保持者释放了锁为止,在我们的例子中就是线程A等待线程B释放锁,在2秒内  不断尝试是否能够获得锁,达到2秒后还未获得锁资源,线程A则结束运行,线程B将获得资源继续执行,死锁解除。

      对于死锁的描述最经典的案例是哲学家进餐(五位哲学家围坐在圆形餐桌旁,人手一根筷子,做一下两件事情:吃饭和思考。要求吃东西的时候停止思考,思考的时候停止吃东西,而且必须使用两根筷子才能吃东西),解决此问题的方法很多,比如引入服务生(资源地调度)、资源分级等方法都可以很好的解决此类死锁问题。在我们Java多线程并发编程中,死锁很难避免,也不容易预防,对付它的最好方法就是测试:提高测试覆盖率,建立有效的边界测试,加强资源监控,这些方法能使得死锁无可遁形,即使发生了死锁现象也能迅速查到原因,提高系统性能。

  • 相关阅读:
    LeetCode --- Roman to Integer
    LeetCode --- Maximum Depth of Binary Tree
    LeetCode --- Minimum Path Sum
    Hashkell 第一篇
    LeetCode --- Partition List
    LeetCode --- Valid Palindrome
    LeetCode --- Longest Consecutive Sequence
    LeetCode --- Insertion Sort List
    数据库lib7第2, 3题(创建索引和触发器)
    LeetCode --- Reverse Integer
  • 原文地址:https://www.cnblogs.com/selene/p/5974349.html
Copyright © 2020-2023  润新知