编写java高质量程序建议读记(5)

建议123:volatile不能保证数据同步

volatile关键字比较少用,原因两点,一是在Java1.5之前该关键字在不同的操作系统上有不同的表现,所带来的问题就是移植性较差;二是比较难设计,而且误用较多,这也导致它的"名誉" 受损。我们知道,每个线程都运行在栈内存中,每个线程都有自己的工作内存(Working Memory,比如寄存器Register、高速缓冲存储器Cache等),线程的计算一般是通过工作内存进行交互的,其示意图如下图所示:

1.jpg

从图上可以看到,线程在初始化时从主内存中加载所需的变量值到工作内存中,然后在线程运行时,如果是读取,则直接从工作内存中读取,若是写入则先写到工作内存中,之后刷新到主内存中,这是JVM的一个简答的内存模型,但是这样的结构在多线程的情况下有可能会出现问题,比如:A线程修改变量的值,也刷新到了主内存,但B、C线程在此时间内读取的还是本线程的工作内存,也就是说它们读取的不是最"新鲜"的值,此时就出现了不同线程持有的公共资源不同步的情况。对于此类问题有很多解决办法,比如使用synchronized同步代码块,或者使用Lock锁来解决该问题,不过Java可以使用volatile更简单地解决此类问题,比如在一个变量前加上volatile关键字,可以确保每个线程对本地变量的访问和修改都是直接与内存交互的,而不是与本线程的工作内存交互的,保证每个线程都能获得最"新鲜"的变量值,直接跳转不是结果上图3、4步骤。volatile变量是否能够保证数据的同步性?

class UnsafeThread implements Runnable {
    // 共享资源
    private volatile int count = 0;
    @Override
    public void run() {
        // 增加CPU的繁忙程度,不必关心其逻辑含义
        for (int i = 0; i < 1000; i++) {
            Math.hypot(Math.pow(92456789, i), Math.cos(i));
        }
        count++;
    }
    public int getCount() {
        return count;
    }
}
public static void main(String[] args) throws InterruptedException {
        // 理想值,并作为最大循环次数
        int value = 1000;
        // 循环次数,防止造成无限循环或者死循环
        int loops = 0;
        // 主线程组,用于估计活动线程数
        ThreadGroup tg = Thread.currentThread().getThreadGroup();
        while (loops++ < value) {
            // 共享资源清零
            UnsafeThread ut = new UnsafeThread();
            for (int i = 0; i < value; i++) {
                new Thread(ut).start();
            }
            // 先等15毫秒,等待活动线程为1
            do {
                Thread.sleep(15);
            } while (tg.activeCount() != 1);
            // 检查实际值与理论值是否一致
            if (ut.getCount() != value) {
                // 出现线程不安全的情况
                System.out.println("循环到:" + loops + " 遍,出现线程不安全的情况");
                System.out.println("此时,count= " + ut.getCount());
                System.exit(0);
  }    }    }

代码定义了一个多线程类,run方法的主要逻辑是共享资源count的自加运算,而且我们还为count变量加上了volatile关键字,确保是从内存中读取和写入的,如果有多个线程运行,也就是多个线程执行count变量的自加操作,count变量会产生脏数据吗?代码运行逻辑如下:1、启动100个线程,修改共享资源count的值。2、暂停15秒,观察活动线程数是否为1(即只剩下主线程再运行),若不为1,则再等待15秒。3、判断共享资源是否是不安全的,即实际值与理想值是否相同,若不相同,则发现目标,此时count的值为脏数据。4、如果没有找到,继续循环,直到达到最大循环为止。运行结果如下:循环到:40 遍,出现线程不安全的情况此时,count= 999。
这只是一种可能的结果,每次执行都有可能产生不同的结果。这也说明我们的count变量没有实现数据同步,在多个线程修改的情况下,count的实际值与理论值产生了偏差,直接说明了volatile关键字并不能保证线程的安全。自加操作。count++表示的是先取出count的值然后再加1,也就是count=count+1,所以,在某个紧邻时间片段内会发生如下神奇的事情(1)第一个时间片段。A线程获得执行机会,因为有关键字volatile修饰,所以它从主内存中获得count的最新值为998,接下来的事情又分为两种类型:如果是单CPU,此时调度器暂停A线程执行,让出执行机会给B线程,于是B线程也获得了count的最新值998。如果是多CPU,此时线程A继续执行,而线程B也同时获得了count的最新值998。(2)、第二个片段:如果是单CPU,B线程执行完+1操作(这是一个原子处理),count的值为999,由于是volatile类型的变量,所以直接写入主内存,然后A线程继续执行,计算的结果也是999,重新写入主内存中。2、如果是多CPU,A线程执行完加1动作后修改主内存的变量count为999,线程B执行完毕后也修改主内存中的变量为999,这两个时间片段执行完毕后,原本期望的结果为1000,单运行后的值为999,这表示出现了线程不安全的情况。这也是要说明的:volatile关键字并不能保证线程安全,它只能保证当前线程需要该变量的值时能够获得最新的值,而不能保证线程修改的安全性。顺便说一下,在上面的代码中,UnsafeThread类的消耗CPU计算是必须的,其目的是加重线程的负荷,以便出现单个线程抢占整个CPU资源的情景,否则很难模拟出volatile线程不安全的情况。

建议124:异步运算考虑使用Callable接口

多线程应用有两种实现方式,一种是实现Runnable接口,另一种是继承Thread类,这两个方法都有缺点:run方法没有返回值,不能抛出异常(这两个缺点归根到底是Runnable接口的缺陷,Thread类也实现了Runnable接口),如果需要知道一个线程的运行结果就需要用户自行设计,线程类本身也不能提供返回值和异常。但是从Java1.5开始引入了一个新的接口Callable,它类似于Runnable接口,实现它就可以实现多线程任务,实现Callable接口的类,只是表明它是一个可调用的任务,并不表示它具有多线程运算能力,还是需要执行器来执行的。

建议125:优先选择线程池

在Java1.5之前,实现多线程比较麻烦,需要自己启动线程,并关注同步资源,防止出现线程死锁等问题,在1.5版本之后引入了并行计算框架,大大简化了多线程开发。我们知道一个线程有五个状态:新建状态(NEW)、可运行状态(Runnable,也叫作运行状态)、阻塞状态(Blocked)、等待状态(Waiting)、结束状态(Terminated),线程的状态只能由新建转变为了运行状态后才能被阻塞或等待,最后终结,不可能产生本末倒置的情况,比如把一个结束状态的线程转变为新建状态,则会抛出异常: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个线程可以反复使用呢?这就是我们要搞清楚的重点。线程池涉及以下几个名词:
1、工作线程(Worker):线程池中的线程,只有两个状态:可运行状态和等待状态,没有任务时它们处于等待状态,运行时它们循环的执行任务。
2、任务接口(Task):这是每个任务必须实现的接口,以供工作线程调度器调度,它主要规定了任务的入口、任务执行完的场景处理,任务的执行状态等。这里有两种类型的任务:具有返回值(异常)的Callable接口任务和无返回值并兼容旧版本的Runnable接口任务。
3、任务对列(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方法,它实现了三个职责。1、创建足够多的工作线程数,数量不超过最大线程数量,并保持线程处于运行或等待状态。2、把等待处理的任务放到任务队列中。3、从任务队列中取出任务来执行。其中此处的关键是工作线程的创建,它也是通过new Thread方式创建的一个线程,只是它创建的并不是我们的任务线程(虽然我们的任务实现了Runnable接口,但它只是起了一个标志性的作用),而是经过包装的Worker线程,execute方法是通过Worker类启动的一个工作线程,执行的是我们的第一个任务,然后改线程通过getTask方法从任务队列中获取任务,之后再继续执行,但问题是任务队列是一个BlockingQuene,是阻塞式的,也就是说如果该队列的元素为0,则保持等待状态,直到有任务进入为止。线程池的创建过程:创建一个阻塞队列以容纳任务,在第一次执行任务时创建做够多的线程(不超过许可线程数),并处理任务,之后每个工作线程自行从任务对列中获得任务,直到任务队列中的任务数量为0为止,此时,线程将处于等待状态,一旦有任务再加入到队列中,即召唤醒工作线程进行处理,实现线程的可复用性。使用线程池减少的是线程的创建和销毁时间,这对于多线程应用来说非常有帮助,比如我们常用的Servlet容器,每次请求处理的都是一个线程,如果不采用线程池技术,每次请求都会重新创建一个新的线程,这会导致系统的性能符合加大,响应效率下降,降低了系统的友好性。

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

Java的线程池实现从根本上来说只有两个:ThreadPoolExecutor类和ScheduledThreadPoolExecutor类,这两个类是父子关系,但是Java为了简化并行计算,还提供了一个Exceutors的静态类,它可以直接生成多种不同的线程池执行器,如单线程执行器、带缓冲功能的执行器等,但归根还是使用ThreadPoolExecutor类或ScheduledThreadPoolExecutor类的封装类。线程池的管理是这样一个过程:首先创建线程池,然后根据任务的数量逐步将线程增大到corePoolSize数量,如果此时仍有任务增加,则放置到workQuene中,直到workQuene爆满为止,然后继续增加池中的数量(增强处理能力),最终达到maximumPoolSize,那如果此时还有任务增加进来呢?这就需要handler处理了,或者丢弃任务,或者拒绝新任务,或者挤占已有任务等。在任务队列和线程池都饱和的情况下,一但有线程处于等待(任务处理完毕,没有新任务增加)状态的时间超过keepAliveTime,则该线程终止,也就说池中的线程数量会逐渐降低,直至为corePoolSize数量为止。我们可以把线程池想象为这样一个场景:在一个生产线上,车间规定是可以有corePoolSize数量的工人,但是生产线刚建立时,工作不多,不需要那么多的人。随着工作数量的增加,工人数量也逐渐增加,直至增加到corePoolSize数量为止。此时还有任务增加怎么办呢?
  好办,任务排队,corePoolSize数量的工人不停歇的处理任务,新增加的任务按照一定的规则存放在仓库中(也就是我们的workQuene中),一旦任务增加的速度超过了工人处理的能力,也就是说仓库爆满时,车间就会继续招聘工人(也就是扩大线程数),直至工人数量到达maximumPoolSize为止,那如果所有的maximumPoolSize工人都在处理任务时,而且仓库也是饱和状态,新增任务该怎么处理呢?这就会扔一个叫handler的专门机构去处理了,它要么丢弃这些新增的任务,要么无视,要么替换掉别的任务。过了一段时间后,任务的数量逐渐减少,导致一部分工人处于待工状态,为了减少开支(Java是为了减少系统的资源消耗),于是开始辞退工人,直至保持corePoolSize数量的工人为止,此时即使没有工作,也不再辞退工人(池中的线程数量不再减少),这也是保证以后再有任务时能够快速的处理。
明白了线程池的概念,Executors提供的几个线程创建线程池的便捷方法:/1、newSingleThreadExecutor:单线程池。顾名思义就是一个池中只有一个线程在运行,该线程永不超时,而且由于是一个线程,当有多个任务需要处理时,会将它们放置到一个无界阻塞队列中逐个处理。2、newCachedThreadPool:缓冲功能的线程。建立了一个线程池,而且线程数量是没有限制的(当然,不能超过Integer的最大值),新增一个任务即有一个线程处理,或者复用之前空闲的线程,或者重亲启动一个线程,但是一旦一个线程在60秒内一直处于等待状态时(也就是一分钟无事可做),则会被终止,这里需要说明的是,任务队列使用了同步阻塞队列,这意味着向队列中加入一个元素,即可唤醒一个线程(新创建的线程或复用空闲线程来处理),这种队列已经没有队列深度的概念了。3、newFixedThreadPool:固定线程数量的线程池。 在初始化时已经决定了线程的最大数量,若任务添加的能力超出了线程的处理能力,则建立阻塞队列容纳多余的任务。返回的是一个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() + "  任务执行完毕---\n");
        // 关闭执行器
        es.shutdown();
    }
}

输出应该没有差别,但输出差别其实很大。输出如下:
 ***开始执行 TaskWithLock 任务***          ***开始执行 TaskWithSync 任务***  
   线程名称:pool-1-thread-2,执行时间: 55s  线程名称:pool-2-thread-1,执行时间: 5s
   线程名称:pool-1-thread-1,执行时间: 55s 线程名称:pool-2-thread-3,执行时间: 7s
   线程名称:pool-1-thread-3,执行时间: 55s 线程名称:pool-2-thread-2,执行时间: 9s
  ---TaskWithLock  任务执行完毕--- ---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定义为所有线程的共享变量。(// 多个线程共享锁 final Lock lock = new ReentrantLock();)两锁之间还有以下4点不同:1、Lock支持更细精度的锁控制:假设读写锁分离,写操作时不允许有读写操作存在,而读操作时读写可以并发执行,这一点内部锁就很难实现。读写锁允许同时有多个读操作但只允许一个写操作,也就是当有一个写线程在执行时,所有的读线程都会阻塞,直到写线程释放锁资源为止,而读锁则可以有多个线程同时执行。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是单进程的多线程语言,一旦线程死锁,则很难通过外科手术的方法使其起死回生,很多时候只有借助外部进程重启应用才能解决问题。递归函数,而且还加上了synchronized关键字,它保证同时只有一个线程能够执行,想想synchronized关键字的作用:当一个带有synchronized关键字的方法在执行时,其他synchronized方法会被阻塞,因为线程持有该对象的锁,递归没有产生死锁那是因为在运行时当前线程(Thread-0)获得了Foo对象的锁(synchronized虽然是标注在方法上的,但实际作用是整个对象),也就是该线程持有了对象的锁,所以它可以多次重入递归方法,可以这样来思考该问题,一个包厢有N把钥匙,分别由N个海盗持有 (也就是我们Java的线程了),但是同一时间只能由一把钥匙打开宝箱,获取宝物,只有在上一个海盗关闭了包厢(释放锁)后,其它海盗才能继续打开获取宝物,这里还有一个规则:一旦一个海盗打开了宝箱,则该宝箱内的所有宝物对他来说都是开放的,即使是“ 宝箱中的宝箱”(即内箱)对他也是开放的。可以用如下代码来表示:

class Foo 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、互斥条件:一个资源每次只能被一个线程使用。2、资源独占条件:一个线程因请求资源在未使用完之前,不能强行剥夺。3、不剥夺条件:线程已经获得的资源在未使用完之前,不能强行剥夺。4、循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。只有满足了这些条件才能产生线程死锁,这也同时告诫我们如果要解决线程死锁问题,就必须从这四个条件入手,一般情况下可以按照以下两种方案解决:(1)避免或减少资源共享:一个资源被多个线程共享,若采用了同步机制,则产生死锁的可能性大,特别是在项目比较庞大的情况下,很难杜绝死锁,对此最好的解决办法就是减少资源共享。(2)使用自旋锁:回到前面的例子,线程A在等待线程B释放资源,而线程B又在等待线程A释放资源,僵持不下,那如果线程B设置了超时时间是不是就可以解决该死锁问题了呢?比如线程B在等待2秒后还是无法获得资源,则自行终结该任务。

public void b() {
        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下的实现类一般采用了长度自行管理的方式(也就是变长)比如定义一个初始长度为5的list但是运行中加入元素超过初始容量,ArrayList会自行扩容确保正常加入元素,BlockingQueue也是集合也实现Collection接口,但他不能扩容,如果队列已满则会报IllegalStateException:Queue full队列已满异常;这是阻塞队列和非阻塞队列一个重要区别:阻塞队列的容量是固定的,非阻塞队列则是变长的。阻塞队列可以在声明时指定队列的容量,若指定的容量,则元素的数量不可超过该容量,若不指定,队列的容量为Integer的最大值。有此区别的原因是:阻塞队列是为了容纳(或排序)多线程任务而存在的,其服务的对象是多线程应用,而非阻塞队列容纳的则是普通的数据元素。阻塞队列的这种机制对异步计算是非常有帮助的,如果阻塞队列已满,再加入任务则会拒绝加入,而且返回异常,由系统自行处理,避免了异步计算的不可知性。可以使用put方法,它会等队列空出元素,再让自己加入进去,无论等待多长时间都要把该元素插入到队列中,但是此种等待是一个循环,会不停地消耗系统资源,当等待加入的元素数量较多时势必会对系统性能产生影响。offer方法可以优化一下put方法)。

建议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);
		//等待发令枪响
		begin.wait();
		//跑步中
		TimeUnit.MILLISECONDS.sleep(score);
		//跑步结束
		end.countDown();
		return score;
	}
	public static void main(String[] args) throws Exception {
		//参赛人数
		int num=10;
		//发令枪响一次
		CountDownLatch begin=new CountDownLatch(1);
		//参与跑步者有多个
		CountDownLatch end=new CountDownLatch(num);
		//每个跑步者一个跑道
		ExecutableElement es=(ExecutableElement) Executors.newFixedThreadPool(num);
		//记录成绩
		List<Future<Integer>>futures=new ArrayList<Future<Integer>>();
		//跑步者就为所有线程等待状态
		for(int i=0;i<num;i++){
			futures.add(((ExecutorService) es).submit(new Runner(begin, end)));
		}
		//发令枪响开始跑步
		begin.countDown();
		//等待所有跑步者完成
		end.wait();
		int count=0;
		//统计总分
		for(Future<Integer>f:futures){
			count+=f.get();
		}
		System.out.println("平均成绩"+count/num);
	}
}

CountDownLatch协调子线程步骤:一个开始计数器,多个结束计数器:1)每一个子线程开始运行,执行代码到begin.await后线程阻塞,等待begin的计数变为0;2)主线程调用begin的countDown方法,使begin的计数器为0;3)每个线程继续运行;4)主线程继续运行下一条语句,end的计数器不为0,主线程等待;5)每个线程运行结束时把end的计数器减1,标志着本线程运行完毕;6)多个线程全部结束,end计数器为0;7)主线程继续执行,打印出结果。类似:领导安排了一个大任务给我,我一个人不可能完成,于是我把该任务分解给10个人做,在10个人全部完成后,我把这10个结果组合起来返回给领导--这就是CountDownLatch的作用)。

建议131:CyclicBarrier让多线程齐步走

解决独立线程在没有线程通信的情况下,在线程汇聚同一源点的问题,java提供了(关卡CyclicBarrier,也翻译成栅栏)

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.println(Thread.currentThread().getName()+"到达会和点");
			cb.wait();
		} catch (InterruptedException e1) {
			// TODO Auto-generated catch block				
		}						
	}
	public static void main(String[] args) throws Exception{
		CyclicBarrier cb=new CyclicBarrier(2,new Runnable() {

			@Override
			public void run() {
				System.out.println("隧道已打通");
			}
		});
		//工人1挖隧道
		new Thread(new worker(cb),"工人1").start();
		//工人1挖隧道
		new Thread(new worker(cb),"工人2").start();
	}
}

这里定义了两个线程和汇聚的关卡,逻辑就是:1)2个线程同时运行实现不同任务执行时间不同。2)1先到达汇合点变为等待状态。3)2到达汇合点满足预先的关卡条件继续执行,此时还有额外的执行动作,执行关卡任务(就是run方法)和唤醒1线程。4)1线程继续执行。CyclicBarrier关卡可以让所有线程全部处于等待状态(阻塞),然后在满足条件的情况下继续执行,这就好比是一条起跑线,不管是如何到达起跑线的,只要到达这条起跑线就必须等待其他人员,待人员到齐后再各奔东西,CyclicBarrier关注的是汇合点的信息,而不在乎之前或者之后做何处理。CyclicBarrier可以用在系统的性能测试中,测试并发性。

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

如何让Java程序跑的更快、效率更高、吞吐量更大:

1、不要在循环条件中计算,每循环一次就会计算一次,会降低系统效率;

2、尽可能把变量、方法声明为final static类型,加上final static修饰后,在类加载后就会生成,每次方法调用则不再重新生成对象了;

3、缩小变量的作用范围,目的是加快GC的回收;

4、频繁字符串操作使用StringBuilder或StringBuffer;

5、使用非线性检索,使用binarySearch查找会比indexOf查找元素快很多,但是使用binarySearch查找时记得先排序

6、覆写Exception的fillInStackTrace方法,fillInStackTrace方法是用来记录异常时的栈信息的,这是非常耗时的动作,如果不需要关注栈信息,则可以覆盖,以提升性能;

7、不建立冗余对象。

程序的运行需要三种资源CPU、内存、I/O,提升CPU的处理速度可以加快代码的执行速度,变现在反回时间缩短了效率提高了,内存是java程序必须考虑的问题,在32位机器上,一个JVM最多能使用2GB的内存,而程序占用内存越大寻址效率也越低,I/O是程序展示和存储数据的主要通道,如果他很缓慢会影响正常的展示效果。随着java升级很多看似正常的优化策略都逐渐过时,优化方法还要自我验证。

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

通过clone方法生成一个对象时,就会不再执行构造函数了,只是在内存中进行数据块的拷贝,看上去似乎应该比new方法的性能好很多,但事实上,JVM对new做了大量的系能优化,(多数都是new出来的所以认识到这点)一般情况下new生成的对象比clone生成的性能方面要好很多。而clone方式只是一个冷僻的生成对象的方式,并不是主流,它主要用于构造函数比较复杂,对象属性比较多,通过new关键字创建一个对象比较耗时间的时候。

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

1、望:观察性能问题症状:性能问题从表象上分两类,一是不可(或很难)重现的偶发性问题,比如线程阻塞,多线程访问共享资源不会死锁但会被阻塞,此时就很难去重现。二是可重现性能问题。

2、闻:性能优化上的闻是关注项目被动产生的信息,包括项目组的技术能力(主要取决于技术经理的技术能力)、文化氛围。群体的习惯和习性以及他们的专注和擅长的领域。注意的是闻并不是主动地去了解,而是由技术(人或应用)自行发挥出的“味道”需要我们敏锐的抓住,这对性能分析有很大的帮助。

3、问:就是技术人员和业务人员(使用者)一起探讨,了解问题的历史状况。

4、切:给出定论,java系统性能也是类似,接触真是的系统数据,需要看设计、代码、日志。系统环境、分析的结论。这里注意所有的一手资料(如报告、非系统信息)都不是百分百可靠,二是测试环境毕竟是测试环境它是证明假设的辅助工具并不能证明方法或策略的正确性。

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

性能目标的重要性:1)性能衡量标准时技术与业务之间的契约:如“非常快”不具有衡量性,如2秒对于开发者和业务者的体会是不同的。2)性能衡量标志是技术优化的目标,性能优化无底线但副作用也越明显,如可读性差,可扩展性降低等。一个好的性能标准应包括以下KPI(key Performance Indicators):1)核心业务的响应时间。(如新闻网站的核心业务是新闻浏览,衡量标准就是打开一个新闻的时间)。2)重要业务的响应时间,重要业务是在系统中占据前沿地位的业务,但不会涉及业务数据的功能(邮件系统的登录)。性能的衡量标准要在一定的环境下。

建议136:枪打出头鸟--解决首要系统性能问题

系统出现性能问题很少是一个功能有问题。一般是一批出现,统计重要导致缓慢的按优先级排除前三解决。解决性能优化要“单线程”小步前进,避免关注点过多而导致精力分散(解决性能问题时,不要把所有的问题都摆在眼前,这只会“扰乱”你的思维,集中精力,找到那个“出头鸟”,解决它,在大部分情况下,一批性能问题都会迎刃而解。

建议137:调整JVM参数以提升性能

如果程序优化达到极致就来到了JVM优化,四个常用的JVM优化手段:
1、调整堆内存大小,JVM两种内存:栈内存(Stack)和堆内存(Heap),栈内存的特点是空间小,速度快,用来存放对象的引用及程序中的基本类型;而堆内存的特点是空间比较大,速度慢,一般对象都会在这里生成、使用和消亡。栈空间由线程开辟,线程结束,栈空间由JVM回收,它的大小一般不会对性能有太大影响,但是它会影响系统的稳定性,超过栈内存的容量时,会抛StackOverflowError错误。可以通过“java -Xss <size>”设置栈内存大小来解决。堆内存的调整不能太随意,调整得太小,会导致Full GC频繁执行,轻则导致系统性能急速下降,重则导致系统根本无法使用;调整得太大,一则浪费资源(若设置了最小堆内存则可以避免此问题),二则是产生系统不稳定的情况,设置方法“java -Xmx1536 -Xms1024m”,可以通过将-Xmx和-Xms参数值设置为相同的来固定堆内存大小;

2、调整堆内存中各分区的比例,JVM的堆内存包括三部分:新生区(Young Generation Space)、养老区(Tenure Generation Space)、永久存储区(Permanent Space 方法区),其中新生成的对象都在新生区,又分为伊甸区(Eden Space)、幸存0区(Survivor 0 Space)和幸存1区(Survivor 1 Space),当在程序中使用了new关键字时,首先在Eden区生成该对象,如果Eden区满了,则触发minor GC,然后把剩余的对象移到Survivor区(0区或者1区),如果Survivor取也满了,则minor GC会再回收一次,然后再把剩余的对象移到养老区,如果养老区也满了,则会触发Full GC(非常危险的动作,JVM会停止所有的执行,所有系统资源都会让位给垃圾回收器),会对所有的对象过滤一遍,检查是否有可以回收的对象,如果还是没有的话,就抛出OutOfMemoryError错误。一般情况下新生区与养老区的比例为1:3左右,设置命令:“java -XX:NewSize=32m -XX:MaxNewSize=640m -XX:MaxPermSize=1280m -XX:NewRatio=5”,该配置指定新生代初始化为32MB(也就是新生区最小内存为32M),最大不超过640MB,养老区最大不超过1280MB,新生区和养老区的比例为1:5.一般情况下Eden Space : Survivor 0 Space : Survivor 1 Space == 8 : 1 : 1)。

3、java程序性能最大的障碍就是垃圾回收不知道它何时发生,也不知道执行时间,可以想办法改变对系统影响,比如启动并行、规定并行回收线程数量等,变更GC的垃圾回收策略,设置命令“java -XX:+UseParallelGC -XX:ParallelGCThreads=20”,这里启用了并行垃圾收集机制,并且定义了20个收集线程(默认的收集线程等于CPU的数量),这对多CPU的系统时非常有帮助的,可以大大减少垃圾回收对系统的影响,提高系统性能;

4、更换JVM,如果所有的JVM优化都不见效,那就只有更换JVM了,比较流行的三个JVM产品:Java HotSpot VM(稳定性可靠性不错)、Oracle JRockit JVM(效率著称)、IBM JVM(稳定)。

建议138:性能是个大“咕咚”

不要被未知吓到跟风,1、没有慢的系统,只有不满足义务的系统;2、没有慢的系统,只有架构不良的系统;3、没有慢的系统,只有懒惰的技术人员;4、没有慢的系统,只有不愿意投入的系统

建议139:大胆采用开源工具

MVC框架有Structs,也有Spring MVC、WebWorker;IoC容器有Spring,也有Coolgle Guice;ORM有Hibernate、MyBatis;日志有经典的log4j、崭新的logback。选择开源工具和框架时要遵循一定的规则:

1、普适性原则:不能有太大的跨度或跳跃性。2、唯一性原则:相同工具选一种,不要让多种相同或功能相似工具共存。3、“大树纳凉”原则:(有名的开源组织)4、精而专原则:精而准不是广泛的。5、高热度原则。

建议140:推荐使用Guava扩展工具包

Guava(石榴)是Google发布的,对JDK的Collection包进行扩展。其中包含了collections、caching、primitives support、concurrency libraries、common annotations、I/O等:

1、Collection:com.goolgle.common.collect包括不可变集合(Immutable Collections)、多值Map、table表和集合工具类。不可变集合包括ImmutableList、ImmutableMap、ImmutableSet、ImmutableSortMap、ImmutableSortedSet等比不可修改集合更容易实现。多值Map。Map是键值对形式。put时如果键重复了则会覆盖原有的值,但一键多值就比较麻烦。使用Guava的Multimap可以解决。集合工具类:Lists、Maps、Sets分别对应List、Map、Set工具类。

2、字符串操作:Guava提供的字符串工具类:Joiner连接器和Splitter拆分器。当然JDK的方法也能实现但是Guava更简单些。Joiner不仅可以连接字符串还可以吧Map中键值对串联起来。

3、基本类型工具:基本类型工具在primitives包中,以基本类型名+S的方式命名的,如Ints是以int的工具类,Doubles是double的工具类。

建议141:Apache扩展包

Apache Commons通用扩展包基本上是每个项目都会使用的,一般情况下lang包用作JDK的基础语言扩展。Apache Commons项目包含非常好用的工具,如DBCP、net、Math等。

建议142:推荐使用Joda日期时间扩展包

Joda可以很好地与现有的日期类保持兼容,在需要复杂的日期计算时使用Joda。日期工具类也可以选择date4j:1、本地格式的日期时间:如当前时间DateTime d=new DataeTime();本地日期格式d.toLocalDate();2、日期计算,比如计算100天后事星期几使用JDK日期类比较麻烦,使用Joda就会很简单。d.plusHours(100).dayofWeek();加100小时候周几。DateTime是一个不可变类型,与String非常相似即使通过plusXX。minusXX等方法进行操作,d仍然不会变。只是生成一个新的对象。但Joda提供一个可变类型的日期对象:MutableDateTime类。

建议143:可以选择多种Collections扩展

三个比较有个性的Collections扩展工具包:1、FastUtil,主要提供两种功能:一种是限定键值类型的Map、List、Set等,另一种是大容量的集合;2、Trove,提供了一个快速、高效、低内存消耗的Collection集合,并且还提供了过滤和拦截功能,同时还提供了基本类型的集合,优势在于性能上,在进行一般的增删改它的相应比JDK集合少一个数量级。3、lambdaj,是一个纯净的集合操作工具,它不会提供任何的集合扩展,只会提供对集合的操作,比如查询、过滤、统一初始化等

建议144:提倡良好的代码风格

提高可读性,良好的编码风格包括以下几种特征:1、整洁;2、统一;3、流行;4、便捷,推荐使用Checkstyle检测代码是否遵循规范。

建议145:不要完全依靠单元测试来发现问题

单元测试的目的是保证各个独立分隔的程序单元的正确性,虽然它能够发现程序中存在的问题(或缺陷、或错误),但是单元测试只是排查程序错误的一种方式,不能保证代码中的所有错误都能被单元测试挖掘出来,原因:1、单元测试不可能测试所有的场景(路径):包括正常场景测试代码的主逻辑、边界数据场景用来测试在边界情况下的数据场景,异常场景测试异常故障下能否按照预期运行;2、代码整合错误是不可避免的;3、部分代码无法(或很难)测试;4、单元测试验证的是编码人员的假设。

建议146:让注释正确、清晰、简洁

坏注释:1、废话式注释、2、故事式注释(太多)。3、不必要注释。4、过时式注释。5、大块式注释。6、流水账式注释。7、转为javaDoc编写的注释。号类型的注释:1、法律版本信息。2、解释意图的注释。3、警告性注释。4、TODO注释。注释不是美化剂,而是催化剂,或为优秀加分,或为拙略减分。

建议147:让接口的职责保持单一

接口职责一定要单一,实现类职责尽量单一(单一职责原则(Single Responsibility Principle,简称SRP)有以下三个优点:1、类的复杂性降低,接口功能单一复杂度减少,当然接口数量就会增多;2、可读性和可维护性提高;3、降低变更风险。以电话通信为例说明如何实施单一职责:1、分析职责。2、设计接口。3、合并实现。

建议148:增强类的可替换性

Java的三大特征:封装、继承、多态;说说多态,一个接口可以有多种实现方式,一个父类可以有多个子类,并且可以把不同的实现或子类赋给不同的接口或父类。多态的好处非常多,其中一点就是增强了类的可替换性,但是单单一个多态特性,很难保证我们的类是完全可以替换的,幸好还有一个里氏替换原则来约束。里氏替换原则:所有引用基类的地方必须能透明地使用其子类的对象。通俗点讲,只要父类型能出现的地方子类型就可以出现,而且将父类型替换为子类型还不会产生任何错误或异常,使用者可能根本就不需要知道是父类型还是子类型。为了增强类的可替换性,在设计类时需要考虑以下三点:1、子类型必须完全实现父类型的方法;2、前置条件可以被放大;3、后置条件可以被缩小。

建议149:依赖抽象而不是实现

此处的抽象是指物体的抽象,比如出行,依赖的是抽象的运输能力,而不是具体的运输交通工具。依赖倒置原则(Dependence Inversion Principle,简称DIP)要求实现解耦,保持代码间的松耦合,提高代码的复用率。DIP的原始定义包含三层含义:1、高层模块不应该依赖底层模块,两者都应该依赖其抽象;2、抽象不应该依赖细节;3、细节应该依赖抽象;DIP在Java语言中的表现就是:1、模块间的依赖是通过抽象发生的,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的;2、接口或抽象类不依赖于实现类;3、实现类依赖接口或抽象类;更加精简的定义就是:面向接口编程。实现模块间的松耦合遵循规则:1、尽量抽象;2、表面类型必须是抽象的;3、任何类都不应该从具体类派生;4、尽量不要覆写基类的方法;5、抽象不关注细节。

建议150:抛弃7条不良的编码习惯

7条经常犯的错误:1)自由格式的代码;2)不使用抽象的代码;3)彰显个性的代码;4)死代码;5)冗余代码;6)拒绝变化的代码;7)自以为是的代码。

建议151:以技术人员自律而不是工人

20条建议技术人员定向培养:1、熟悉工具。2、使用IDE。3、坚持编码。 4、编码前思考。  5、坚持重构。6、多写文档。 7、保持程序版本的简单性。8、做好备份。 9、做单元测试。 10、不要重复发明轮子。11、不要拷贝。 12、让代码充满灵性。13、测试自动化。 14、做压力测试。15、“剽窃”不可耻。 16、坚持向敏捷学习。17、重里更重面。 18、分享。19、刨根问底。 20、横向扩展。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值