编写高质量Java代码的151个建议(125-135)

序言

声明

因为简书篇幅限制,151个建议只能分开.这里是 [125-135]
本书来源 @Linux公社 的 <<编写高质量Java代码的151个习惯>> 的电子书
作者:秦小波 出版社:北京 机械工业出版社 2011.11
如有侵权请联系 @小猪童鞋QQ聊天链接 删除


@编写高质量Java代码的151个建议(1-40)
@编写高质量Java代码的151个建议(41-70)
@编写高质量Java代码的151个建议(71-90)
@编写高质量Java代码的151个建议(91-110)
@编写高质量Java代码的151个建议(111-124)
@编写高质量Java代码的151个建议(125-135)
@编写高质量Java代码的151个建议(136-151)


致本文读者:

如果小伙伴发现有地方有错误,请联系我 @小猪童鞋QQ聊天链接
欢迎小伙伴和各位大佬们一起学习,可私信也可通过上方QQ链接

我的环境:

eclipse version: 2019-03 (4.11.0) Build id: 20190314-1200
jdk1.8
Lombok.jar 插件 安装指南看这里 @简单粗暴节省JavaBean代码插件 Lombok.jar

建议125:优先选择线程池

在Java1.5之前,实现多线程比较麻烦,需要自己启动线程,并关注同步资源,防止出现线程死锁等问题,在1.5版本之后引入了并行计算框架,大大简化了多线程开发。我们知道一个线程有五个状态:新建状态(NEW)、可运行状态(Runnable,也叫作运行状态)、阻塞状态(Blocked)、等待状态(Waiting)、结束状态(Terminated),线程的状态只能由新建转变为了运行状态后才能被阻塞或等待,最后终结,不可能产生本末倒置的情况,比如把一个结束状态的线程转变为新建状态,则会出现异常,例如如下代码会抛出异常:

public static void main(String[] args) throws InterruptedException {
        // 创建一个线程,新建状态
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程正在运行");
            }
        });
        // 运行状态
        t.start();
        // 是否是运行状态,若不是则等待10毫秒
        while (!t.getState().equals(Thread.State.TERMINATED)) {
            TimeUnit.MICROSECONDS.sleep(10);
        }
        // 直接由结束转变为云心态
        t.start();
    }

此段程序运行时会报java.lang.IllegalThreadStateException异常,原因就是不能从结束状态直接转变为运行状态,我们知道一个线程的运行时间分为3部分:T1为线程启动时间,T2为线程的运行时间,T3为线程销毁时间,如果一个线程不能被重复使用,每次创建一个线程都需要经过启动、运行、销毁时间,这势必增大系统的响应时间,有没有更好的办法降低线程的运行时间呢?

T2是无法避免的,只有通过优化代码来实现降低运行时间。T1和T2都可以通过线程池(Thread Pool)来缩减时间,比如在容器(或系统)启动时,创建足够多的线程,当容器(或系统)需要时直接从线程池中获得线程,运算出结果,再把线程返回到线程池中___ExecutorService就是实现了线程池的执行器,我们来看一个示例代码:

public static void main(String[] args) throws InterruptedException {
        // 2个线程的线程池
        ExecutorService es = Executors.newFixedThreadPool(2);
        // 多次执行线程体
        for (int i = 0; i < 4; i++) {
            es.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName());
                }
            });
        }
        // 关闭执行器
        es.shutdown();
    }

此段代码首先创建了一个包含两个线程的线程池,然后在线程池中多次运行线程体,输出运行时的线程名称,结果如下:

pool-1-thread-1
        pool-1-thread-2
        pool-1-thread-1
        pool-1-thread-2

本次代码执行了4遍线程体,按照我们之前阐述的" 一个线程不可能从结束状态转变为可运行状态 ",那为什么此处的2个线程可以反复使用呢?这就是我们要搞清楚的重点。

线程池涉及以下几个名词:

工作线程(Worker):线程池中的线程,只有两个状态:可运行状态和等待状态,没有任务时它们处于等待状态,运行时它们循环的执行任务。
任务接口(Task):这是每个任务必须实现的接口,以供工作线程调度器调度,它主要规定了任务的入口、任务执行完的场景处理,任务的执行状态等。这里有两种类型的任务:具有返回值(异常)的Callable接口任务和无返回值并兼容旧版本的Runnable接口任务。
任务对列(Work Quene):也叫作工作队列,用于存放等待处理的任务,一般是BlockingQuene的实现类,用来实现任务的排队处理。
  我们首先从线程池的创建说起,Executors.newFixedThreadPool(2)表示创建一个具有两个线程的线程池,源代码如下:

public class Executors {
    //生成一个最大为nThreads的线程池执行器
  public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }

}

这里使用了LinkedBlockingQueue作为队列任务管理器,所有等待处理的任务都会放在该对列中,需要注意的是,此队列是一个阻塞式的单端队列。线程池建立好了,那就需要线程在其中运行了,线程池中的线程是在submit第一次提交任务时建立的,代码如下:

public Future<?> submit(Runnable task) {
        //检查任务是否为null
        if (task == null) throw new NullPointerException();
        //把Runnable任务包装成具有返回值的任务对象,不过此时并没有执行,只是包装
        RunnableFuture<Object> ftask = newTaskFor(task, null);
        //执行此任务
        execute(ftask);
        //返回任务预期执行结果
        return ftask;
    }

此处的代码关键是execute方法,它实现了三个职责。

创建足够多的工作线程数,数量不超过最大线程数量,并保持线程处于运行或等待状态。
把等待处理的任务放到任务队列中
从任务队列中取出任务来执行
  其中此处的关键是工作线程的创建,它也是通过new Thread方式创建的一个线程,只是它创建的并不是我们的任务线程(虽然我们的任务实现了Runnable接口,但它只是起了一个标志性的作用),而是经过包装的Worker线程,代码如下:

private final class Worker implements Runnable {
// 运行一次任务
    private void runTask(Runnable task) {
        /* 这里的task才是我们自定义实现Runnable接口的任务 */
        task.run();
        /* 该方法其它代码略 */
    }
    // 工作线程也是线程,必须实现run方法
    public void run() {
        try {
            Runnable task = firstTask;
            firstTask = null;
            while (task != null || (task = getTask()) != null) {
                runTask(task);
                task = null;
            }
        } finally {
            workerDone(this);
        }
    }
    // 任务队列中获得任务
    Runnable getTask() {
        /* 其它代码略 */
        for (;;) {
            return r = workQueue.take();
        }
    }
}

此处为示意代码,删除了大量的判断条件和锁资源。execute方法是通过Worker类启动的一个工作线程,执行的是我们的第一个任务,然后改线程通过getTask方法从任务队列中获取任务,之后再继续执行,但问题是任务队列是一个BlockingQuene,是阻塞式的,也就是说如果该队列的元素为0,则保持等待状态,直到有任务进入为止,我们来看LinkedBlockingQuene的take方法,代码如下:

public E take() throws InterruptedException {
        E x;
        int c = -1;
        final AtomicInteger count = this.count;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lockInterruptibly();
        try {
            try {
                // 如果队列中的元素为0,则等待
                while (count.get() == 0)
                    notEmpty.await();
            } catch (InterruptedException ie) {
                notEmpty.signal(); // propagate to a non-interrupted thread
                throw ie;
            }
            // 等待状态结束,弹出头元素
            x = extract();
            c = count.getAndDecrement();
            // 如果队列数量还多于一个,唤醒其它线程
            if (c > 1)
                notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
        if (c == capacity)
            signalNotFull();
        // 返回头元素
        return x;
    }

分析到这里,我们就明白了线程池的创建过程:创建一个阻塞队列以容纳任务,在第一次执行任务时创建做够多的线程(不超过许可线程数),并处理任务,之后每个工作线程自行从任务对列中获得任务,直到任务队列中的任务数量为0为止,此时,线程将处于等待状态,一旦有任务再加入到队列中,即召唤醒工作线程进行处理,实现线程的可复用性。

使用线程池减少的是线程的创建和销毁时间,这对于多线程应用来说非常有帮助,比如我们常用的Servlet容器,每次请求处理的都是一个线程,如果不采用线程池技术,每次请求都会重新创建一个新的线程,这会导致系统的性能符合加大,响应效率下降,降低了系统的友好性。

建议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最完整的构造函数,其他的构造函数都是引用该构造函数实现的,我们逐步来解释这些参数的含义。

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

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
    }
}

方法method1是synchronized修饰的,方法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)、避免或减少资源共享

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

例如一个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多线程并发编程中,死锁很难避免,也不容易预防,对付它的最好方法就是测试:提高测试覆盖率,建立有效的边界测试,加强资源监控,这些方法能使得死锁无可遁形,即使发生了死锁现象也能迅速查到原因,提高系统性能。

建议129: 适当设置租在队列长度

阻塞队列BlockingQueue扩展了Queue,Collection接口,对元素的插入和提取使用了"阻塞"处理,我们知道Collection下的实现类一般都采用了长度自增的自行管理方式(也就是变长) 比如这样的代码是可以正常运行的:

public class Test129 {
    public static void main(String[] args) {
        //定义初始长度为5
        List<String> list = new ArrayList<String>();
        //加入10个元素
        for (int i = 0; i < 10; i++) {
            list.add("");
        }
    }
}

上述代码定义了列表的初始长度为5,在实际使用的时候,当加入的元素超过起初容量的时候,ArrayList会自动扩容,确保能够正常加入元素,.那BlockingQueue也是集合,也实现了Collection接口,它的容量是否会自行管理呢?我们来看代码:

public class Test129 {
    public static void main(String[] args) {
        //定义初始长度为 5
        BlockingQueue<String> bq = new ArrayBlockingQueue<String>(5);
        //加入10个元素
        for (int i = 0; i < 10; i++) {
            bq.add("");
        }
    }
}

打印结果报错

Exception in thread "main" java.lang.IllegalStateException: Queue full
    at java.util.AbstractQueue.add(Unknown Source)
    at java.util.concurrent.ArrayBlockingQueue.add(Unknown Source)
    at cn.icanci.test_151.Test129.main(Test129.java:23)

显然,BlockingQueue是不可以自行扩容的.队列报错已满异常.这是非阻塞队列和阻塞队列的一个重要区别,非阻塞队列是看可以变长的.阻塞队列在声明的时候就要声明队列的容量,若指定的容量,则元素不可以超过此容量,若不指定,默认值是Integer 的最大值.
阻塞队列和非阻塞队列有此区别的原因是阻塞队列是了容纳(或排序)多线程任务而存在的,其服务的对象是多线程应用,而非阻塞的队列容纳的是普通的数据元素.我们看一下ArrayBolckingQueue类最常用的add方法

  public boolean add(E e) {
        if (offer(e))
            return true;
        else
            throw new IllegalStateException("Queue full");
    }

 public boolean offer(E e) {
        checkNotNull(e);
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            if (count == items.length)
                return false;
            else {
                enqueue(e);
                return true;
            }
        } finally {
            lock.unlock();
        }
    }

上面在增加元素的时候,如果判断队列已经满了,就返回false,表示插入失败,之后再包装成队列满异常.此处需要注意offer方法,如果我们直调用offer方法插入元素,再超出容量的情况下,除了会返回false,不会有其他的提示信息,那就会造成数据的"默默丢失",这就是它与非阻塞队列的不同之处.
阻塞队列对于这种机制的异步计算是非常有帮助的,例如我们定义深度为100的阻塞队列容纳100个任务,多个线程从该队列中获取任务并处理,当所有的线程都在繁忙,并且队列中的数量已经是100的时候,也就预示这系统压力很大,而且处理结果的返回时间也比较长,于是滴101个想要加入的时候,队列拒绝加入,并且返回异常.有系统自行处理,避免了运算的不可知性,但是如果应用期望无论等待多久都要运行该任务,不希望返回异常,那么应该怎么处理呢?
此时就需要使用BlockingQueue接口定义的put方法了,它的作用就是把元素加入到队列中,但是它和add,offer方法不同,他会等待队列空出元素,,然后再把自己加入进去

    public void put(E e) throws InterruptedException {
        checkNotNull(e);
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            while (count == items.length)
                notFull.await();
            enqueue(e);
        } finally {
            lock.unlock();
        }
    }

pue的目的就是确保元素肯定会加入到队列中去,.问题是此种等待是一个循环,会不停止的消耗系统资源.怎么解决呢?JDK已经想到了这个问题,它提供了有超时时间的offer方法.实现方法和put类似.只是使用Condition的awaitNanos方法来判断当前线程已经等待了多少纳秒.超时就返回false.
与插入元素对应,去除元素也有不同的实现

建议130: 使用CountDownLatch协调子线程

思考这样的一个案例:百米赛跑,多个参加赛跑的人员在听到强声命令时候,就开始跑步,到达终点时候结束计时,然后统计平均成绩.这里有两点需要考虑:一是发令枪响,所有的跑步
步者(线程)接收到的出发信号,此处涉及裁判(主线程)如何通知跑步者( 子线程)的问题;二是如何获知所有的跑步者完成了赛跑,也就是主线程如何知道子线程已经全部完成,这有很多种实现方式,此处我们使用CountDownLatch工具类来实现,代码如下:

      static class Runner implements Callable<Integer> {
      //开始信号
      private CountDownLatch begin;//结束信号
      private CountDownLatch end;
      public Runner (CountDownLatch. _begin, CountDownLatch_ end) {
      begin =_ _begin;end =_ end;
      @Override
      public Integer call() throws Exception {
      //跑步的成绩
      int score = new Random() .nextInt (25) i//等待发令枪响起begin.awalt() ;//跑步中......
      TimeUnit . MILLISECONDS . sleep (score) ;//跑步者已经跑完全程end. countDown() ;return score;
      }}
   public static void main (String (]  args) throws Exception{
     int num = 10;
      CountDownLatch begin = new Coun tDownLatch (1) ;
      CountDownLatch end =new CountDownLatch (num)  
      ExecutorService es = Executors . newFixedThreadPool (num) ;
      List<Future<Integer» futures = new ArrayList<Future< Integer»() ;
     for(inti=0;i<num;  i++) 
      futures . add (es. submit (new Runner (begin, end))) ;)
      begin . countDown() ;
      end.await() ;
      int count = 0; 
      for(Future<Integer> f : futures){
          count += f,get();
      }
      System.out,println(count/num);
}

CountDownLatch类是一个倒数的同步计数器,在程序中启动了两个计数器: -一个是开始计数器begin,表示的是发令枪:另外是结束计数器,- - 共有10个,表示的是每个线程的执行情况,也就是跑步者是否跑完比赛。程序执行逻辑如下:
1)10 个线程都开始运行,执行到begin.await 后线程阻塞,等待begin的计数变为0.
2)主线程调用begin的countDown方法,使begin的计数器为0
3)10个线程继续运行。
4)主线程继续运行下一个语句,end的计数器不为0,主线程等待。
5)每个线程运行结束时把end的计数器减1,标志着本线程运行完毕。
6)10个线程全部结束,end 计数器为0。
7)主线程继续执行,打印出成绩平均值。
CountDownLatch的作用是控制一个计数器,每个线程在运行完毕后会执行countDown,表示自己运行结束,这对于多个子任务的计算特别有效,比如一一个异步任务需要拆分成10个子任务执行,主任务必须要知道子任务是否完成,所有子任务完成后才能进行合并计算,从而保证了一个主任务的逻辑正确性。这和我们的实际工作非常类似,比如领导安排了一个大任务给我,我一一个人不可能完成,于是我把该任务分解给10个人做,在10个人全部完成后,我把这10个结果组合起来返回给领导一这 就是CountDownLatch的作用。

建议131: CyclicBarrier让多线程同步

思考这样-一个案例:两个工人从两端挖掘隧道,各自独立奋战,中间不沟通,如果两人在汇合点处碰头了,则表明隧道已经挖通。这描绘的也是在多线程编程中,两个线程独立运行,在没有线程间通信的情况下,如何解决两个线程汇集在同一原点的问题。Java提供了CyclicBarrier (关卡,也有翻译为栅栏)工具类来实现,代码如下:

     static class Worker implements Runnable {
    // 关卡
    private CyclicBarrier cb;

    public Worker (CyclicBarrier. cb) {
    cb =_ cb;
}

    public void run() {
    try {
        Thread . sleep (new Random() .nextInt (1000)) ;
        System. out . print1n (Thread. currentThread() .getName() + "- 到达汇合点");
        //到达汇合点cb. avait() 1
        } catch (Exception e) {
        //异常处理

        }

    public static void main (String[] args) throws Exception {
        //设置汇集数量,以及汇集完成后的任务
        CyclicBarrier cb = new cyclicBarrier(2, new Runnab1e() {
        public void run(){
        System. out . print1n("隧道巳经打通! ");

        }
        });
    //工人1挖隧道
    new Thread (new Worker(cb),"工人1") .start() ;//工人2挖隧道
    new Thread (new Worker(cb), "工人2") .start() ;
    }

在这段程序中,定义了一个需要等待2个线程汇集的CyclicBarrier关卡,并且定义了完成汇集后的任务(输出“隧道已经打通!”),然后启动了2个线程(也就是2个工人)开始执行任务。代码逻辑如下:

  1. 2个线程同时开始运行,实现不同的任务,执行时间不同。
    2)“工人1”线程首先到达汇合点(也就是cb.await语句),转变为等待状态。
    3)“工人2”线程到达汇合点,满足预先的关卡条件(2 个线程到达关卡),继续执行。此时还会额外的执行两个动作:执行关卡任务(也就是run方法)和唤醒“工人1”线程。
    4)“工人1”线程继续执行。
    CyclicBarrier关卡可以让所有线程全部处于等待状态( 阻塞),然后在满足条件的情况下继续执行,这就好比是一条起跑线,不管是如何到达起跑线的,只要到达这条起跑线就必须等待其他人员,待人员到齐后再各奔东西,CyclicBarrier 关注的是汇合点的信息,而不在乎之前或之后做何处理。
    CyclicBarrier可以用在系统的性能测试中,例如我们编写了-一个核心算法,但不能确定其可靠性和效率如何,我们就可以让N个线程汇集到测试原点上,然后“一声令下”,所有的线程都引用该算法,即可观察出算法是否有缺陷。
第十章 性能和效率

在这个快餐时代,系统一直在提速, 从未停步过,从每秒百万条指令的CPU到现在的每秒万亿条指令的多核CPU,从最初发布一个帖子需要等待N小时才有回复到现在的微博,一个消息在几分钟内就可以传遍全球;从N天才能完成的一-次转账交易,到现在的即时转账-一我们进入了一个光速 发展的时代,我们享受着,也在被追逐着一榨干硬件资源, 加速所有能加速的,提升所有能提升的。

建议132: 提升Java性能的基本方法

Java从诞生之日起就被质疑:字节码在JVM中运行是否会比机器码直接运行的效率会低很多?很多技术高手、权威网站都有类似的测试和争论,从而来表明Java比C (或C++)更快或效率相同。此类话题我们暂且不表(这类问题的争论没完没了,也许等到我们退休的时候,还想找个活动脑筋的方式,此类问题就会是最好的选择),我们先从如何提高Java的性能方面入手,看看怎么做才能让Java程序跑得更快,效率更高,吞吐量更大。
(1)不要在循环条件中计算
如果在循环(如for循环、while 循环)条件中计算,则每循环- -遍就要计算一次,这会降低系统效率,就比如这样的代码:

//每次循环都要计算count*2
    while (i<count*2){
        //Do Something
    }
//应该替换为: .1/只计算- -遍
    int total =count * 2;
    while (1<tota1){
        //DO something
    }

(2)尽可能把变量、方法声明为final static 类型假设要将阿拉伯数字转换为中文数字,其定义如下:

public String toChineseNum(int num) {
//中文数字
string[] cns  ={"零","查","贰","塞","肆","伍","陆","柒","制","玖"};
    return cns [num] :
}

每次调用该方法时都会重新生成-一个cns数组,注意该数组不会改变,属于不变数组,在这种情况下,把它声明为类变量,并且加上final static修饰会更合适,在类加载后就生成了该数组,每次方法调用则不再重新生成数组对象了,这有助于提高系统性能,代码如下。
(3)缩小变量的作用范围
关于变量,能定义在方法内的就定义在方法内,能定义在-一个循环体内的就定义在循环体内,能放置在一个t.y...c.块内的就放置在该块内,其目的是加快GC的回收。
(4)频繁字符串操作使用StringBuilder或StringBuffer
虽然String的联接操作(“+”号)已经做了很多优化,但在大量的追加操作上StringBuilder或StringBuffer还是比“+”号的性能好很多,例如这样的代码:

     String str = "Log file 1s read......";
     for(int i=0;i<max;i++){
     //此处生成三个对象
       str += "1og"+ i;

应该修改为:

      StringBuilder sb = nev stringBuilder (20000) ;8b. append("Log file is ready......");
      for(int i=0;i<max;i++){
          eb.append("log”+ 1);
      string 1og = sb. tostring(); 

(5)使用非线性检索
如果在ArrayList中存储了大量的数据,使用indexOf查找元素会比java.utils. Collections.binarySearch的效率低很多,原因是binarySearch是二分搜索法,而indexOf使用的是逐个元素比对的方法。这里要注意:使用binarySearch搜索时,元素必须进行排序,否则准确性就不可靠了。
(6)覆写Exception的flInStackTrace方法
我们在第8章中提到flInStackTrace方法是用来记录异常时的栈信息的,这是非常耗时的动作,如果我们在开发时不需要关注栈信息,则可以覆盖之,如下覆盖flInStackTrace的自定义异常会使性能提升10倍以上:

class MyException extends Exception {
          public Throwable fillInStackTrace1) {
              return this;
    }

(7)不建立冗余对象
不需要建立的对象就不能建立,说起来很容易,要完全遵循此规则难度就很大了,我们经常就会无意地创建冗余对象,例如这样- - 段代码:

 public void doSomething() {
      //异常信息
      string exceptionMeg = "我出现异常了,快来就救我! ";try {
      Thread. sleep(10) ;} catch (Exception e) {
      //转换为自定又运行期异常
      throw new MyException (e, exceptionMeg) ;
      }

注意看变量exceptionMsg,这个字符串变量在什么时候会被用到?只有在抛出异常时它才有用武之地,那它是什么时候创建的呢?只要该方法被调用就创建,不管会不会抛出异常。我们知道异常不是我们的主逻辑,不是我们代码必须或经常要到达的区域,那为了这个不经常出现的场景就每次都多定义-一个字符串变量,合适吗?而且还要占用更多的内存!所以,在catch块中定义exceptionMsg方法才是正道:需要的时候才创建对象。
我们知道运行一-段程序需要三种资源: CPU、内存、I/O, 提升CPU的处理速度可以加快代码的执行速度,直接表现就是返回时间缩短了,效率提高了:内存是Java程序必须考虑的问题,在32位的机器上,一个JVM最多只能使用2GB的内存,而且程序占用的内存越大,寻址效率也就越低,这也是影响效率的-一个因素。I/O 是程序展示和存储数据的主要通道,如果它很缓慢就会影响正常的显示效果。所以我们在编码时需要从这三个方面入手接口(当然了,任何程序优化都是从这三方面入手的)。
Java的基本优化方法非常多,这里不再罗列,相信读者也有自己的小本本,上面所 罗列的性能优化方法可能远比这里多,但是随着Java的不断升级,很多看似很正确的优化策略就逐渐过时了(或者说已经失效了),这一点还需要读者注意。最基本的优化方法就是自我验证,找出最佳的优化途径,提高系统性能,不可盲目信任。

建议133: 若非必要,不要克隆对象

通过clone方法生成一个对象时,就会不再执行构造函数了,只是在内存中进行数据块的拷贝,此方法看上去似乎应该比new方法的性能好很多,但是Java的缔造者们也认识到“二八原则”,80% (甚至更多)的对象是通过new关键字创建出来的,所以对new在生成对象(分配内存、初始化)时做了充分的性能优化,事实上,一般情况下new生成的对象比clone生成的性能方面要好很多,例如这样的代码。

private static class Apple implements C1oneable [
      public Object c1one() l
      try (
        return super .clone() ;
      }
      catch (C1 oneNot SupportedException e) (
      throw new Error() ;
      public static void main(Stringl) args) {
        final int maxLoops = 10 * 10000;int 1oops = 0;11푸쑓마미
        long start = System. nanoTime() ;11 "튝"*4
        Apple apple = new Apple() ;while (++1oaps < maxLoops) l
        app1e.c1one();
        long mid = System. nanoTime() ;
        System. out. println("clone: " + (mid - start) + " ns");
        while (--loaps > 0) {
         new Apple() ;
        }
      long end = System. nanoTime() ;
      System. out . print1n("new: " + (end - mid) + " ns");

在上面的代码中,Apple 是一个简单的可拷贝类,用两种方式生成了10万个苹果:一种是通过克隆技术,-种是通过直接种植( 也就是new关键字),按照我们的常识想当然地会认为克隆肯定比new要快,但是结果却是这样的:
clone方法生成对象耗时: 18731431 ns
new生成对象耗时: 2391924 ns
不用看具体的数字,数数位数就可以了:clone方法花费的时间是8位数,而new方法是7位数,用new生成对象比clone方法快很多!原因是Apple的构造函数非常简单,而且JVM对new做了大量的性能优化,而clone方式只是一个冷僻的生成对象方式,并不是主流,它主要用于构造函数比较复杂,对象属性比较多,通过new关键字创建-一个对象比较耗时间的时候。

注意 克隆对象并不比直接生成对象效率高。

建议134: 推荐使用""望闻问切的方式诊断性能

“望闻问切”是中医诊断疾病的必经步骤,“望”是指观气色,“闻”是指听声息,“问”.是指询问症状,“切”是指摸脉象,合称“四诊”,经过这四个步骤,大夫基本上就能确认病症所在,然后加以药物调理,或能还以病人健康身躯。

-个应用系统如果出现性能问题,不管是偶发性问题还是持久性问题,都是系统“生病”的表现,需要工程师去诊断,然后对症下药。我们可以把Java的性能诊断也分为此四个过程(把我们自己想象成医生吧,只是我们的英文名字不叫Doctor,而是叫做TroubleShooter) :

(1)望
观察性能问题的症状。有人投诉我们开发出的系统性能慢,如蜗牛爬行,执行一个操作,在等待它返回的过程中,用户已经完成了倒水、喝茶、抽烟等一系列消遣活动,但系统还是没返回结果!其实这是个好现象,至少我们能看到症状,从而可以对症下药。性能问题从表象上来看可以分为两类:

不可(或很难)重现的偶发性问题

比如线程阻塞,在某种特殊条件下,多个线程访问共享资源时会被阻塞,但不会形成死锁,这种情况很难去重现,当用户打电话投诉时,我们自已赶到现场症状已经消失了,然后1个月内再也没有出现过,当我们都认为“磨合”期已过,系统已经正常运行的时候,又接到了类似的投诉,崩溃呀!对于这种情况,“望”已经不起作用了,不要为了看到症状而花费大量的时间和精力,可以采用后续提到的“闻问切”方式。

可重现的性能问题

客户打电话给我们,反映系统性能缓慢,不需要我们赶到现场,自己观察一下生产机就可以发现部分交易缓慢,CPU过高,可用内存较低等问题,在这种情况下我们至少要测试三个有性能问题的交易( 或者三个与业务相关而技术无关的功能,或者与技术有关而业务无关的功能),为什么是三个呢?因为“永远不要带两块手表”,这会致使无法验证和校对。

比如三个不同的输入功能,都是用户输入信息,然后保存到数据库中,但是三个交易的性能都非常缓慢,通过初步的“望”我们就可以基本确认是与数据库或数据驱动相关的问题;若是只有一个交易缓慢,其他两个正常,那就可以大致定位到-一个面:该交易的逻辑层出现问题。

(2)闻
中医上的“闻”是大夫听(或嗅)患者不自觉发出的声音和气味,在性能优化上的“闻"则是关注项目被动产生的信息,其中包括:项目组的技术能力(主要取决于技术经理的技术能力)、文化氛围、群体的习惯和习性,以及他们专注和擅长的领域等,各位读者可能要疑惑了:中医上“闻”的对象是病人,而为什么这里“闻”的对象却是开发团队呢?
我們込祥来思考垓向題,如果是-一个人(个体)生病了,找大夫如此処理是没有任何同題的,但是如果是人奥(群体)生病了,那如何追尋送个根源昵?假没人是上帝例造的,如果有一群外星生物説“人奥都有自私的缺陥",那是不是座垓去現察一下上帝?了解込个缺陷是源于他的可慣性効作述是技能缺乏,或者是“文化侍承".対于-一个Java座用来説,我竹就是“上帝",我佝創造了他,給了他生命(能答送行),給了他尊門(用戸需要它),給了他炙魂(解决了止努向題),那- - 旦他生病,是不是座垓車視一下我仇込些“上帝”昵?或者我仂得自我反省一下昵?

如果項目組的技木能力很強,有資深的数据庠寺家,有頂尖的架杓姉,也有首席程序員,那性能向題广生的根源就虚垓定位在无意訳的代碍缺陷上。

如果項目組的文化氛國很精様,組員不交流,没有固定的代碣規范,缺乏整体的架枸等,那性能向題的根源就可能存在于某个配置上,或者相互的接口調用.上。

如果項目組已経ヨ慣了某- -个框架,而且也可慣了框架的狆神約束,那性能的根源就可能是有人越述了框架的か約。

需要注意的是,“”并不是主効地去了解,而是由技木(人或座用)自行擇爰出的“味道”,需要我們要敏鋭地抓住,込可能会対性能分析有非常大的幇助。

(3)向

“向"就是与技木人員(締造者)和止努人員(使用者) - -起探対核向題,了 解性能向題的万史状况,了解“慢”声生的前因后果,比如対于韭多人員我仞可以咨詢:

性能是不是一苴込祥慢, 从何肘起慢到不能忍受?

郷一个操作或梛-一奥操作最慢,大概的等待肘伺是多長?用戸的操作可慣是什幺,是喜玖快捷鍵逐是喜吹用鼠柝点む?

在什幺吋同段最慢,韭多高峰期是否有滯頓現象,韭多低谷是否也緩慢?其他坊向渠道,如移効没各是否也有效率向題?

韭努品神和数量有没有激増,操作人員是否大規模増加?

是否在止多上爰生せ重大事項或重要変更,当吋的性能如何?用戸的操作慣有没有改変,或者用戸是否自定乂了某些功能?

而対于技木人員,我們就要从技木角度来詢向性能向題了,而且由于技木人員対系統了如指掌,可能会“无意沢"地回避向題,我們座垓有技巧地処理送奥向題,例如可以込祥来詢向技木人員:

系統日志是否記彖了緩慢信息,是否可以回放緩慢交易?鏝慢吋系統的CPU、内存、IO如何?

高峰期和低谷肘止多并爰数量、并爰交易神炎、達接池的数量、数据的達接數量如何?最早接到用戸投訴是什幺吋候,是如何赴理的,代化后如何?数据量的増矢幅度如何,是否有万史数据処理策略?
系统是否有不稳定的情况,是否出现过宕机,是否产生过javacore文件?最后一次变更是何时,变更的内容是哪些,变更后是否出现过性能问题?操作系统、网络、存储、应用软件等环境是否发生过改变?

通过与技术人员和业务人员交流,我们可以对性能问题有一个整体认识,避免“管中窥豹,只见一斑”的偏见,更加有助于我们分析和定位问题。

(4)切

“切”是“四诊”的最后-一个环节,也是最重要的环节,这个环节结束我们就要给出定论:问题出在什么地方,该如何处理等。Java的性能诊断也是类似的,“切”就要我们接触真实的系统数据,需要去看设计,看代码,看日志,看系统环境,然后是思考分析,最后给出结论。在这一-环节中,需要注意两点: - -是所有的非- -手资料( 如报告、非系统信息)都不是100%可信的,二是测试环境毕竟是测试环境,它只是证明假设的辅助工具,并不能证明方法或策略的正确性。

曾经遇到过这样-一个案例,有一一个24小时运行的高并发系统,从获得的资料上看,在出现偶发性的性能故障前系统没有做过任何变更,网络也没变更过,业务也没有过大的变动,业务人员的形容是“一夜之间系统就变慢了”,而且该问题在测试机上不能模拟重现。接到任务后,马上进行“望闻问”,都没有太大的收获。进入到“切”环节时,对大量的日志进行跟踪分析调试,最终锁定到了加密机上:加密机属于多个系统的共享资源,当排队加密数据时就有可能出现性能问题,最终的解决方案是增加一台加密机,于是系统性能恢复正常。

性能优化是一一个漫长的工作,特别是对于偶发性的性能问题,不要期望找到“名医”立刻就能见效,这是不现实的,深入思考,寻根探源,最终必然能找到根源所在。中医上有一句话“病来如山倒,病去如抽丝”,系统诊断也应该这样-一个过程,切忌急躁。

注意性能诊断遵循 “望闻问切”,不可过度急躁。

建议135: 必须定义性能的衡量标准

出现性能问题不可怕,可 怕的是没有目标,用户只是说“我希望它非常快”,或者说“和以前一样快”,在这种情况下,我们就需要把制定性能衡量标准放在首位了,原因有两个:
(1)性能衡量标准是技术与业务之间的契约
“非常快”是一个直观性的描述,它不具有衡量的可能性,对技术人员来说,-一个请求在2秒钟之内响应就可以认为是“非常快”了,但对业务人员来说,“非常快”指的是在0.5秒内看到结果一看, 出现偏差了。如果我们不解决这种偏差,就有可能出现当技术人员认为优化结束的时候,而业务人员还认为系统很慢,仍然需要提高继续性能,于是拒不签收验收文档,这就产生商务麻烦了。
(2)性能衡量标志是技术优化的目标
性能优化是无底线的,性能优化得越厉害带来的副作用也就明显,例如代码的可读性差,可扩展性降低等,比如- -个乘法计算,我们一-般是这样写代码的:
int i =100*16;
如果我们为了提升系统性能,使用左移的方式来计算,代码如下:
int i =100<<4;
性能确实提高了,但是也带来了副作用,比如代码的可读性降低了很多,要想让其他人员看明白这个左移是何意,就需要加上注释说“把100扩大16倍”,这在项目开发中是非常不合适的。因此为了让我们的代码保持优雅,减少“坏味道”的产生,就需要定义一个优化目标:优化到什么地步才算结束。
明白了性能标准的重要性,就需要在优化前就制定好它,-一个好的性能衡量标准应该包括以下KPI (Key Performance Indicators):
核心业务的响应时间。一个新闻网站的核心业务就是新闻浏览,它的衡量标准就是打
开一个新闻的时间;一个邮件系统的核心业务就是邮件发送和接收速度; -一个管理型系统的核心就是流程提交,这也就是它的衡量标准。
重要业务的响应时间。重要业务是指在系统中占据前沿地位的业务,但是不会涉及业务数据的功能,例如一个业务系统需要登录后才能操作核心业务,这个登录交易就是它的重要交易,比如邮件系统的登录。
当然,性能衡量标准必须在- -定的环境下,比如网络、操作系统、硬件设备等确定的情况下才会有意义,并且还需要限定并发数、资源数(如10万数据和1000万的数据响应时间肯定不同)等,当然很多时候我们并没有必要白纸黑字地签署- - 份协约,我们编写性能衡量标准更多地是为了确定一个目标,并尽快达到业务要求而已.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345