序言
声明
因为简书篇幅限制,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个工人)开始执行任务。代码逻辑如下:
- 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万的数据响应时间肯定不同)等,当然很多时候我们并没有必要白纸黑字地签署- - 份协约,我们编写性能衡量标准更多地是为了确定一个目标,并尽快达到业务要求而已.