Java面试题剖析(基础篇) | 第一篇: 线程基础、多线程、线程锁及线程池

一、Java线程:概念与原理

1.1 进程和线程

我们所熟识的Windows、Linux、Uinux操作系统,是支持多线程的,它可以同时执行很多个线程,也支持多进程,因此它们都是支持多线程多进程的操作系统。但什么是进程?什么是线程?上述操作系统又是如何支持多线程、多进程的呢?

对操作系统来说,资源分配的基本单位是进程,而调度的基本单位是线程。

1.1.1 进程概念

进程是指一个内存中运行的应用程序,一个进程中可以启动多个线程。比如在Windows系统中,一个运行的exe就是一个进程。每个进程都有自己独立的一块内存空间,它们只能使用自己的内存空间,并且保留程序的运行状态,这个也为进程切换提供了基础。

1.1.2 线程概念

线程是指进程中的一个执行流程,一个进程中可以运行多个线程,比如java.exe进程中可以运行很多线程。线程总是属于某个进程,每个线程有自己的程序计数器,有自己的栈。进程中的多个线程共享进程的内存,它们可以访问和操作相同的对象。

java是单线程编程语言,你要是不主动创建线程,那么就默认只有主线程。

例:下图是一段最简单的Java代码,它表述了一个程序的执行过程。因为只有一条执行路径,所以这个执行过程是单线程的。

Java面试题剖析(基础篇) | 第一篇: 线程基础、多线程、线程锁及线程池_第1张图片

1.1.3 注意点

1、在一个时间点上,CPU只有一个线程在运行。

CPU的执行速度很快,即使有很多个线程,它也可以在很短的时间内把他们都通通执行一遍(当然这里面涉及到线程的上下文切换)。对我们人来说,CPU的执行速度太快了,因此看起来就像是在同时执行一样。但实际上,在一个时间点,CPU只有一个线程在运行。

什么才是真正的多线程?如果你的机器是多CPU,或者多核,这确确实实是多线程。

2、对多线程的理解

CPU在一个时间点上确实只能执行一个线程,但是多线程不是由于双核或者多核才叫多线程。是由于,当很多个线程在并行执行的时候,CPU根据一定的线程调度算法,频繁的进行线程切换。所以在一个时间段上,可以看做很多线程在并发执行,这是人们通常意义上理解的多线程。

1.2 Java线程状态

Java线程状态,也就是Java线程的生命周期。下图标注了线程的各个状态以及状态变化的场景:

初始状态(New) 
当线程对象被创建后,则开启线程初始状态(Thread t = new MyThread())。 
就绪状态(Runnable) 
当调用线程对象的start()方法(t.start()),线程进入了就绪状态。这时只能说明此线程已经做好了准备,随时等待CPU调度执行,并不是说明调用了start方法线程立即就会执行。 
运行状态(Running) 
当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。就绪状态是进入运行状态的唯一入口。也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中。 
阻塞状态(Blocked)
处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,此时进入阻塞状态。直到其进入到就绪状态,才有机会再次被CPU调用以进入到运行状态。根据阻塞原因的不同,阻塞状态又可以分为三种:

等待阻塞:运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态;
同步阻塞:线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态;
其他阻塞:通过调用线程sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程将重新转入就绪状态;
死亡状态(Dead):当线程执行完或者因异常退出run()方法,该线程结束生命周期。

二、Java多线程实现

2.1 线程创建

在JAVA里面,JAVA的线程是通过java.lang.Thread类来实现的,每一个Thread对象代表一个新的线程。创建新线程有两种方法:第一个是继承Thread类,另一个是实现Runnable接口。JVM启动时会有一个由主方法即main方法(public static void main())所定义的线程,这个线程叫主线程。可以通过创建Thread的实例来创建新的线程。你只需要new一个Thread对象,一个新的线程也就出现了。每个线程都是通过某个特定的Thread对象所对应的方法run()来完成其操作的,方法run()称为线程体。

2.1.1 继承Thread类

Thread类中有个run方法,它的子类应该重写该方法。代码如下:

package com.moi.test.ThreadTest;

public class ThreadTest1 extends Thread{
    public void run(){
        for(int i =0;i<10;i++){
            System.out.println("child:"+i);
        }
    }
    public static void main(String[] args) {
        ThreadTest1 t = new ThreadTest1();
        t.start();
        for(int i =0;i<10;i++){
            System.out.println("main:"+i);
        }
    }
}

输出结果如下:

Java面试题剖析(基础篇) | 第一篇: 线程基础、多线程、线程锁及线程池_第2张图片

可以看出,主线程和子线程是交替着在执行,或者说是并行执行。

2.1.2 实现Runnable接口

使用实现Runnable接口的方式创建和启动新线程。代码如下:

package com.moi.test.ThreadTest;

public class ThreadTest2 implements Runnable{

    @Override
    public void run() {
        for(int i =0;i<10;i++){
            System.out.println("child:"+i);
        }
    }
    public static void main(String[] args) {
        ThreadTest2 t1 = new ThreadTest2();
        new Thread(t1, "线程1").start();
        for(int i =0;i<10;i++){
            System.out.println("main:"+i);
        }
    }

}

输出结果如下:

Java面试题剖析(基础篇) | 第一篇: 线程基础、多线程、线程锁及线程池_第3张图片

同样,主线程和子线程并行执行。

下面是单线程执行方式:

package com.moi.test.ThreadTest;

public class ThreadTest2 implements Runnable{

    @Override
    public void run() {
        for(int i =0;i<10;i++){
            System.out.println("child:"+i);
        }
    }
    public static void main(String[] args) {
        ThreadTest2 t1 = new ThreadTest2();
        t1.run();
        for(int i =0;i<10;i++){
            System.out.println("main:"+i);
        }
    }

}

可以看出,上面代码中并没有开启一个新的线程,而是直接调用run方法。那么它的执行情况是什么样的呢?结果见下图:

Java面试题剖析(基础篇) | 第一篇: 线程基础、多线程、线程锁及线程池_第4张图片

显然,main方法必须要等到run方法执行完成之后才能继续往下执行。这是一个单线程的执行流程。

2.1.3 总结扩展

上面介绍了Java中多线程的2种实现方式。在主线程中开启一个新的线程是Java多线程中最简单的情景,在实际开发中肯定会遇到更复杂的需求。下面介绍一下Java多线程编程中需要注意的几点:

1、多线程与单线程比拼,多线程不一定就快。

从上图可以发现(上图是基于单CPU的情况),当并发执行累加操作不超过百万次时,速度会比串行执行累加操作要慢。那么,为什么并发执行的速度会比串行慢呢?这是因为线程有创建和上下文切换的开销。

创建线程时构造Thread对象的成本是非常大的,通过查看Thread源码简要分析(后续会有博文深入剖析):

Java面试题剖析(基础篇) | 第一篇: 线程基础、多线程、线程锁及线程池_第5张图片

在new Thread()的时候会调用init方法,init方法中会有系统安全性检查的操作,即图中的SecurityManager,这个操作的逻辑是很复杂的。同时,在该方法后续还会有分配线程栈的操作,也会有一定的开销。

线程上下文切换是基于CPU的线程调度算法,尽管CPU计算能力超强,频繁的线程切换也会带来很大的开销。

2、Java中2种多线程实现方式比较

(a) 由于在Java中类是单继承的,所以当一个类继承Thread类时就不能再继承其他父类。如果只是实现了Runnable接口,那么并不影响该类继承其他父类;

(b) 实现Runnable接口方式有利于程序操作共享资源(后续会有博文结合代码深入剖析);

(c) 实现Runnable接口方式创建的线程和线程池更配哦。

解释:

通过Executor创建线程池后,我们只需要把实现了Runnable的类的对象实例放入线程池,那么线程池就自动维护线程的启动、运行、销毁。有童鞋就会问了,Thread类也实现了Runnable接口,它实例化之后也可以放入线程池啊。是的,它完全可以放入线程池,但是就像我上文提到的,频繁构造Thread对象的成本是非常大的,所以在实际开发中我们更适合采用实现Runnable接口的方式创建线程。

综上:如果我们不需要重写Thread类的其它方法,创建子线程的最好方式就是简单的实现Runnable接口。

三、Java线程池

在前面的内容中,我们使用线程的时候就去创建一个线程,这样实现起来非常简便,但是就会有一个问题:如果并发的线程数量很多,这样频繁创建线程就会大大降低系统的效率。大量的线程也会占用内存资源并且可能会导致Out of Memory。同时,大量的线程回收也会给GC带来很大的压力。

那么有没有一种办法使得线程可以复用,就是执行完一个任务,并不被销毁,而是可以继续执行其他的任务?Java线程池就可以达到这样的效果。

3.1 Java中的ThreadPoolExecutor类基础

java.uitl.concurrent.ThreadPoolExecutor类是线程池中最核心的一个类,因此如果要透彻地了解Java中的线程池,必须先了解这个类。下面我们通过源码来简单的了解下ThreadPoolExecutor类的实现(后续会发布关于源码剖析的博文)。

3.1.1 ThreadPoolExecutor类构造方法

在ThreadPoolExecutor类中提供了四个构造方法:

public class ThreadPoolExecutor extends AbstractExecutorService {
    ...
    public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,
        BlockingQueue workQueue);
    public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,
        BlockingQueue workQueue,ThreadFactory threadFactory);
    public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,
        BlockingQueue workQueue,RejectedExecutionHandler handler);
    public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,
        BlockingQueue workQueue,ThreadFactory threadFactory,RejectedExecutionHandler handler);
    ...
        }

从上面的代码可以得知,ThreadPoolExecutor继承了AbstractExecutorService类,并提供了四个构造器,事实上,通过观察每个构造器的源码具体实现,发现前面三个构造器都是调用的第四个构造器进行的初始化工作。

下面解释一下构造器中各个参数的含义:

  • corePoolSize:核心池的大小。在默认情况下,创建了线程池后,线程池中的线程数为0,当有任务进来之后,就会创建一个线程去执行任务,当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到缓存队列当中;
  • maximumPoolSize:线程池最大线程数,它表示在线程池中最多能创建多少个线程;
  • keepAliveTime:表示线程没有任务执行时最多保持多久时间会终止。默认情况下,只有当线程池中的线程数大于corePoolSize时,如果一个线程空闲的时间达到keepAliveTime,则会终止,直到线程池中的线程数不超过corePoolSize;
  • unit:参数keepAliveTime的时间单位,有7种取值;
  • workQueue:任务缓存队列。它是一个阻塞队列,用来存储等待执行的任务。一般来说,阻塞队列有以下几种选择:
    ArrayBlockingQueue:基于数组的先进先出队列,此队列创建时必须指定大小;
    LinkedBlockingQueue:基于链表的先进先出队列,如果创建时没有指定此队列大小,则默认为Integer.MAX_VALUE;
    SynchronousQueue:这个队列比较特殊,它不会保存提交的任务,而是将直接新建一个线程来执行新来的任务;
  • threadFactory:线程工厂,主要用来创建线程;
  • handler:表示当拒绝处理任务时的策略,有以下四种取值:
    ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。 
    ThreadPoolExecutor.DiscardPolicy:也是丢弃任务,但是不抛出异常。 
    ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)
    ThreadPoolExecutor.CallerRunsPolicy:由调用线程处理该任务 

3.1.2 除构造方法外的几个常用方法

  • execute()方法:execute()方法是ThreadPoolExecutor的核心方法。通过这个方法可以向线程池提交一个任务,交由线程池去执行。
  • submit()方法:submit()方法也是用来向线程池提交任务的,但是它和execute()方法不同,它能够返回任务执行的结果。通过源码发现它实际上还是调用的execute()方法,只不过它利用了Future来获取任务执行结果。

       Java面试题剖析(基础篇) | 第一篇: 线程基础、多线程、线程锁及线程池_第6张图片

  • shutdown()方法:shutdown()方法用于线程池的关闭。它不会立即终止线程池,而是要等所有任务缓存队列中的任务都执行完后才终止,但再也不会接受新的任务。
  • shutdownNow()方法:shutdownNow()方法也是用于线程池的关闭。与shutdown()方法不同的是,它会立即终止线程池,并尝试打断正在执行的任务,并且清空任务缓存队列,返回尚未执行的任务。

3.1.3 线程池状态

在ThreadPoolExecutor中定义了线程池的状态,如下:

private static final int RUNNING    = -1 << COUNT_BITS;
private static final int SHUTDOWN   =  0 << COUNT_BITS;
private static final int STOP       =  1 << COUNT_BITS;
private static final int TIDYING    =  2 << COUNT_BITS;
private static final int TERMINATED =  3 << COUNT_BITS;

当创建线程池后,初始时,线程池处于RUNNING状态;

如果调用了shutdown()方法,则线程池处于SHUTDOWN状态,此时线程池不能够接受新的任务,它会等待所有任务执行完毕;

如果调用了shutdownNow()方法,则线程池处于STOP状态,此时线程池不能接受新的任务,并且会去尝试终止正在执行的任务;

当线程池处于SHUTDOWN或STOP状态,并且所有工作线程已经销毁,任务缓存队列已经清空或执行结束后,线程池被设置为TERMINATED状态。

3.1.4 线程池中的线程初始化

默认情况下,创建线程池之后,线程池中是没有线程的,需要提交任务之后才会创建线程。

在实际中如果需要线程池创建之后立即创建线程,可以通过以下两个方法办到:

  • prestartCoreThread():初始化一个核心线程;
  • prestartAllCoreThreads():初始化所有核心线程

源码如下:

public boolean prestartCoreThread() {
        return workerCountOf(ctl.get()) < corePoolSize &&
        addWorker(null, true);
        }
public int prestartAllCoreThreads() {
    int n = 0;
    while (addWorker(null, true))
        ++n;
    return n;
}

3.1.5 线程池容量的动态调整

ThreadPoolExecutor提供了动态调整线程池容量大小的方法:setCorePoolSize()和setMaximumPoolSize()。

  • setCorePoolSize:设置核心池大小
  • setMaximumPoolSize:设置线程池最大能创建的线程数目大小

当上述参数从小变大时,ThreadPoolExecutor进行线程赋值,还可能立即创建新的线程来执行任务。

3.2 Java线程池实现原理

3.2.1 图解+源码分析线程池实现原理

下面先看一个线程池执行流程图:

结合流程图和源码来简要分析线程池执行任务流程。源码和注释如下:

public void execute(Runnable command) {
        //首先,判断提交的任务command是否为null,若是null,则抛出空指针异常;
        if (command == null)
        throw new NullPointerException();
        /*
         * Proceed in 3 steps:
         *
         * 1. If fewer than corePoolSize threads are running, try to
         * start a new thread with the given command as its first
         * task.  The call to addWorker atomically checks runState and
         * workerCount, and so prevents false alarms that would add
         * threads when it shouldn't, by returning false.
         * 如果正在运行的线程数小于corePoolSize,那么将调用addWorker 方法来创建一个新的线程,并将该任务作为新线程的第一个任务来执行。
       当然,在创建线程之前会做原子性质的检查,如果条件不允许,则不创建线程来执行任务,并返回false.
         *
         * 2. If a task can be successfully queued, then we still need
         * to double-check whether we should have added a thread
         * (because existing ones died since last checking) or that
         * the pool shut down since entry into this method. So we
         * recheck state and if necessary roll back the enqueuing if
         * stopped, or start a new thread if there are none.
         *如果一个任务成功进入阻塞队列,那么我们需要进行一个双重检查来确保是我们已经添加一个线程(因为存在着一些线程在上次检查后他已经死亡)或者
       当我们进入该方法时,该线程池已经关闭。所以,我们将重新检查状态,线程池关闭的情况下则回滚入队列,线程池没有线程的情况则创建一个新的线程。
         * 3. If we cannot queue task, then we try to add a new
         * thread.  If it fails, we know we are shut down or saturated
         * and so reject the task.
         * 如果任务无法入队列(队列满了),那么我们将尝试新开启一个线程(从corepoolsize到扩充到maximum),如果失败了,那么可以确定原因,要么是
       线程池关闭了或者饱和了(达到maximum),所以我们执行拒绝策略。
         */
        int c = ctl.get();
        // 1.如果当前线程数量小于corePoolSize,则创建并启动线程。
        if (workerCountOf(c) < corePoolSize) {
        // 成功,则返回
        if (addWorker(command, true))
        return;
        c = ctl.get();
        }
        //2.步骤1失败,则尝试进入阻塞队列,
        if (isRunning(c) && workQueue.offer(command)) {
        int recheck = ctl.get();
        // 入队列成功,重新检查线程池状态,如果状态不是RUNNING而且remove成功,则拒绝任务
        if (! isRunning(recheck) && remove(command))
        reject(command);
        //如果当前worker数量为0,通过addWorker(null, false)创建一个线程,其任务为null
        else if (workerCountOf(recheck) == 0)
        addWorker(null, false);
        }
        // 3. 步骤1和2失败,则尝试将线程池的数量由corePoolSize扩充至maxPoolSize,如果失败,则拒绝任务
        else if (!addWorker(command, false))
        reject(command);
        }

addWorker(Runnable firstTask, boolean core)的主要任务是创建并启动线程。它会根据当前线程的状态和给定的值(corePoolSize or maximumPoolSize)来判断是否可以创建一个线程。(这里不再贴源码分析,感兴趣的童鞋可以自行翻阅源码。后续会发布关于源码剖析的博文)

通过流程图和源码分析总结如下:

首先前面进行空指针检查,workerCountOf()方法能够取得当前线程池中的线程的总数,取得当前线程数与核心池大小比较:

  • 如果当前线程池中的线程数目小于corePoolSize,则每来一个任务,就会创建一个线程去执行这个任务;
  • 如果当前线程池中的线程数目>=corePoolSize,则每来一个任务,会尝试将其添加到任务缓存队列当中,若添加成功,则该任务会等待空闲线程将其取出去执行;若添加失败(一般来说是任务缓存队列已满),则会尝试创建新的线程去执行这个任务;
  • 如果当前线程池中的线程数目达到maximumPoolSize,则会采取任务拒绝策略进行处理;
  • 如果线程池中的线程数量大于 corePoolSize时,如果某线程空闲时间超过keepAliveTime,线程将被终止,直至线程池中的线程数目不大于corePoolSize;如果允许为核心池中的线程设置存活时间,那么核心池中的线程空闲时间超keepAliveTime,线程也会被终止。

3.2.2 Java线程池初使用

前面我们讨论了关于线程池的实现原理,下面我们结合实际代码来看一下它的具体使用。代码如下:

package com.moi.test.ThreadTest;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class Test {
    public static void main(String[] args) {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(3, 10, 200, TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue(5));
        for(int i=0;i<10;i++){
            MyTask myTask = new MyTask(i);
            executor.execute(myTask);
            System.out.println("线程池中线程数目:"+executor.getPoolSize()+",队列中等待执行的任务数目:"+
                    executor.getQueue().size());
        }
        executor.shutdown();
    }
}
class MyTask implements Runnable {
    private int taskNum;
    public MyTask(int num) {
        this.taskNum = num;
    }
    @Override
    public void run() {
        System.out.println("正在执行task "+taskNum);
        try {
            Thread.currentThread().sleep(4000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("task "+taskNum+"执行完毕");
    }
}

执行结果:

Java面试题剖析(基础篇) | 第一篇: 线程基础、多线程、线程锁及线程池_第7张图片

从执行结果可以看出,当线程池中线程的数目大于3时,便将任务放入任务缓存队列里面,当任务缓存队列满了之后,便创建新的线程(因为结果图中task9和task8执行的比任务队列中的3/4/5/6/7早,所以是创建了新线程来执行8/9)。如果上面程序中,将for循环中改成执行16个任务,就会抛出任务拒绝异常了。如下:

Java面试题剖析(基础篇) | 第一篇: 线程基础、多线程、线程锁及线程池_第8张图片

3.2.3 常见的四种线程池介绍和比较

在java doc中,并不提倡我们直接使用ThreadPoolExecutor,而是使用Executors类中提供的几个静态方法来创建线程池:

  • newFixedThreadPool
public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
        0L, TimeUnit.MILLISECONDS,
        new LinkedBlockingQueue());
        }
public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
        return new ThreadPoolExecutor(nThreads, nThreads,
        0L, TimeUnit.MILLISECONDS,
        new LinkedBlockingQueue(),
        threadFactory);
        }

固定大小的线程池,可以指定线程池的大小,该线程池corePoolSize和maximumPoolSize相等,阻塞队列使用的是LinkedBlockingQueue,大小为整数最大值。

该线程池中的线程数量始终不变,当有新任务提交时,线程池中有空闲线程则会立即执行,如果没有,则会暂存到阻塞队列。对于固定大小的线程池,不存在线程数量的变化。同时使用无界的LinkedBlockingQueue来存放执行的任务。当任务提交十分频繁的时候,LinkedBlockingQueue迅速增大,存在着耗尽系统资源的问题。而且在线程池空闲时,即线程池中没有可运行任务时,它也不会释放工作线程,还会占用一定的系统资源,需要shutdown。

  • newSingleThreadExecutor
public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
        (new ThreadPoolExecutor(1, 1,
        0L, TimeUnit.MILLISECONDS,
        new LinkedBlockingQueue()));
        }
public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
        return new FinalizableDelegatedExecutorService
        (new ThreadPoolExecutor(1, 1,
        0L, TimeUnit.MILLISECONDS,
        new LinkedBlockingQueue(),
        threadFactory));
        }

单个线程线程池,只有一个线程的线程池,阻塞队列使用的是LinkedBlockingQueue,若有多余的任务提交到线程池中,则会被暂存到阻塞队列,待空闲时再去执行。按照先入先出的顺序执行任务。

  • newCachedThreadPool
public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
        60L, TimeUnit.SECONDS,
        new SynchronousQueue());
        }
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
        60L, TimeUnit.SECONDS,
        new SynchronousQueue(),
        threadFactory);
        }

缓存线程池,缓存的线程默认存活60秒。线程的核心池corePoolSize大小为0,核心池最大为Integer.MAX_VALUE,阻塞队列使用的是SynchronousQueue。SynchronousQueue是一个直接提交的阻塞队列,它总会迫使线程池增加新的线程去执行新的任务。在没有任务执行时,当线程的空闲时间超过keepAliveTime(60秒),则工作线程将会终止被回收;当提交新任务时,如果没有空闲线程,则创建新线程执行任务,会导致一定的系统开销。如果同时有大量任务被提交,而且任务执行的时间不是特别快,那么线程池便会新增出等量的线程池处理任务,这很可能会很快耗尽系统的资源。

  • newScheduledThreadPool
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
        return new ScheduledThreadPoolExecutor(corePoolSize);
        }
public static ScheduledExecutorService newScheduledThreadPool(
        int corePoolSize, ThreadFactory threadFactory) {
        return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory);
        }

定时线程池,该线程池可用于周期性地去执行任务,通常用于周期性的同步数据。

使用示例如下(代码中4个线程池都在一个main方法里面执行,读者在运行的时候需要加注释单独运行):

package com.moi.test.ThreadTest;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class PoolDemo {
    private static Runnable getThread(final int i) {
        return new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(i);
            }
        };
    }

    public static void main(String args[]) {
        //FixedThreadPool
        ExecutorService fixPool = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 10; i++) {
            fixPool.execute(getThread(i));
        }
        fixPool.shutdown();//需要主动shutdown

        //SingleThreadPool
        ExecutorService singPool = Executors.newSingleThreadExecutor();
        for (int i=0;i<10;i++){
            singPool.execute(getThread(i));
        }
        singPool.shutdown();//需要主动shutdown

        //CachedThreadPool 不需要主动shutdown
        ExecutorService cachePool = Executors.newCachedThreadPool();
        for (int i=1;i<=10;i++){
            cachePool.execute(getThread(i));
        }

        //ScheduledThreadPool
        ScheduledExecutorService ses = Executors.newScheduledThreadPool(10);
        ses.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(4000);
                    System.out.println(Thread.currentThread().getId() + "执行了");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, 0, 2, TimeUnit.SECONDS);
    }
}

ScheduledThreadPool有两种周期性执行任务的方法:

scheduleAtFixedRate是以固定的频率去执行任务,周期是指每次执行任务成功执行之间的间隔。

scheduleWithFixedDelay是以固定的延时去执行任务,延时是指上一次执行成功之后和下一次开始执行的之前的时间。

运行结果如下:

FixedThreadPool

Java面试题剖析(基础篇) | 第一篇: 线程基础、多线程、线程锁及线程池_第9张图片

SingleThreadPool 按顺序执行

Java面试题剖析(基础篇) | 第一篇: 线程基础、多线程、线程锁及线程池_第10张图片

CachedThreadPool(60s之后自动释放资源,结束执行)

Java面试题剖析(基础篇) | 第一篇: 线程基础、多线程、线程锁及线程池_第11张图片

ScheduledThreadPool 周期性执行

Java面试题剖析(基础篇) | 第一篇: 线程基础、多线程、线程锁及线程池_第12张图片

3.2.4 总结扩展

不建议使用Executors直接创建线程池,而是通过ThreadPoolExecutor的方式,这样的处理方式可以让代码阅读者更加明确线程池的运行规则,规避资源耗尽的风险。示例代码如下(FaceBook工程师创建线程池):

private static ExecutorService createDefaultExecutorService(Args args) {
        SynchronousQueue executorQueue = new SynchronousQueue();
        return new ThreadPoolExecutor(args.minWorkerThreads, args.maxWorkerThreads, 60L, TimeUnit.SECONDS,
        executorQueue);
        }

Executors各个方法的弊端:
1)newFixedThreadPool和newSingleThreadExecutor:
  主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM。
2)newCachedThreadPool和newScheduledThreadPool:
  主要问题是线程数最大数是Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至OOM。

1、如何选择线程池大小

线程池的大小决定着系统的性能,过大或者过小的线程池都无法发挥最优的系统性能。

当然线程池的大小也不需要做的太过于精确,只需要避免过大和过小的情况。一般来说,确定线程池的大小需要考虑CPU的数量,内存大小,任务是计算密集型还是IO密集型等因素

NCPU = CPU的数量

UCPU = 期望对CPU的使用率 0 ≤ UCPU ≤ 1

W/C = 等待时间与计算时间的比率

如果希望处理器达到理想的使用率,那么线程池的最优大小为:

线程池大小=NCPU *UCPU(1+W/C)

Java中获取CPU数量

Runtime.getRuntime().availableProcessors();

2、线程池工厂

在前面创建线程池的时候大家可能注意到了ThreadFactory参数,它代表线程池工厂。Executors的线程池如果不指定线程池工厂会使用Executors中的DefaultThreadFactory,默认线程池工厂创建的线程都是非守护线程。

后续的“Java高级”博文中会详细介绍何为线程工厂、如何自定义线程工厂、何为守护线程等。

3、线程池扩展

ThreadPoolExecutor是可以拓展的,它提供了几个可以在子类中改写的方法:beforeExecute,afterExecute和terimated。在执行任务的线程中将调用beforeExecute和afterExecute,这些方法中还可以添加日志,计时,监视或统计收集的功能,还可以用来输出有用的调试信息,帮助系统诊断故障。

4、手动创建线程池的几个注意点

  • 任务独立。每个任务之间应该是相互独立的,必须出现线程死锁。
  • 合理配置阻塞时间过长的任务。如果任务阻塞时间过长,那么即使不出现死锁,线程池的性能也会变得很糟糕。在Java并发包里可阻塞方法都同时定义了限时方式和不限时方式。例如Thread.join,BlockingQueue.put,CountDownLatch.await等,如果任务超时,则标识任务失败,然后中止任务或者将任务放回队列以便随后执行。这样,无论任务的最终结果是否成功,这种办法都能够保证任务总能继续执行下去。
  • 合理设置线程池大小参考上文公式。
  • 选择合适的阻塞队列FixedThreadPool和SingleThreadExecutor都使用了无界的阻塞队列,无界阻塞队列会消耗很大的内存,如果使用了有界阻塞队列,它会规避内存占用过大的问题。但是当任务填满有界阻塞队列,新的任务该怎么办?在使用有界队列时,需要选择合适的拒绝策略,队列的大小和线程池的大小必须一起调节。对于非常大的或者无界的线程池,可以使用SynchronousQueue来避免任务排队,直接创建新线程处理任务。

四、Java线程锁

4.1 线程安全概念

当多个线程访问某一个类(对象或方法)时,这个类始终能表现出正确的行为,那么这个类(对象或方法)就是线程安全的。

线程安全是通过加锁机制来实现的。在多线程访问的情况下,当一个线程访问该类的某个数据时,加锁会对数据进行保护,其他线程不能进行访问直到该线程读取完,其他线程才可使用。不会出现数据不一致或者数据污染。

线程不安全就是不提供数据访问保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据。这里的加锁机制常见的如:synchronized、lock等。

4.2 常见加锁机制

加锁机制这里介绍synchronized和lock。

4.2.1 synchronized修饰符

synchronized:它的意思是同步,同步的概念是对共享资源进行同步。Java中可以在任意对象的方法或者代码块上加锁,而加锁的这段代码称为“互斥区”或“临界区”。下面通过具体代码来描述synchronized的作用。

不使用synchronized实例(代码A):

package com.moi.test.ThreadTest;
public class MyThread implements Runnable{
    private int count = 5;
    @Override
    public void run() {
        count--;
        System.out.println(Thread.currentThread().getName() + " count:" + count);
    }

    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        Thread thread1 = new Thread(myThread, "thread1");
        Thread thread2 = new Thread(myThread, "thread2");
        Thread thread3 = new Thread(myThread, "thread3");
        Thread thread4 = new Thread(myThread, "thread4");
        Thread thread5 = new Thread(myThread, "thread5");
        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
        thread5.start();
    }
}

上述代码描述的是多个线程对同一个对象的数据进行修改,我们期望对count变量进行逐步修改,直到为0。但运行结果并不是我们想要的。结果如下:

Java面试题剖析(基础篇) | 第一篇: 线程基础、多线程、线程锁及线程池_第13张图片

通过结果可以看出,程序并没有按照我们的期望来运行,也就是说并没有同步进行。在上面代码中,count变量就是共享资源,当多线程访问共享资源时,我们需要加锁来保证同步性。下面对程序进行改进。

使用synchronized实例(代码B):

package com.moi.test.ThreadTest;
public class MyThread implements Runnable{
    private int count = 5;
    @Override
    public synchronized  void run() {//通过synchronized关键字来修饰run方法
        count--;
        System.out.println(Thread.currentThread().getName() + " count:" + count);
    }

    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        Thread thread1 = new Thread(myThread, "thread1");
        Thread thread2 = new Thread(myThread, "thread2");
        Thread thread3 = new Thread(myThread, "thread3");
        Thread thread4 = new Thread(myThread, "thread4");
        Thread thread5 = new Thread(myThread, "thread5");
        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
        thread5.start();
    }
}

运行结果如下图:

Java面试题剖析(基础篇) | 第一篇: 线程基础、多线程、线程锁及线程池_第14张图片

从上图可以看出,程序运行时同步修改了count变量的值。所以,当多个线程访问MyThread 的run方法时,如果使用了synchronized修饰,那个多线程就会以排队的方式进行处理(这里排队是按照CPU调度的先后顺序而定的)。一个线程想要执行synchronized修饰的方法里的代码,首先是尝试获得锁,如果拿到锁,执行synchronized代码体的内容,如果拿不到锁的话,这个线程就会不断的尝试获得这把锁,直到拿到为止,而且多个线程同时去竞争这把锁,也就是会出现锁竞争的问题。

当然,也可以通过synchronized修饰代码块的方法解决同步问题(代码C):

package com.moi.test.ThreadTest;
public class MyThread implements Runnable{
    private int count = 5;
    @Override
    public void run() {//通过synchronized关键字来修饰run方法
        synchronized(this){
            count--;
            System.out.println(Thread.currentThread().getName() + " count:" + count);
        }
    }

    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        Thread thread1 = new Thread(myThread, "thread1");
        Thread thread2 = new Thread(myThread, "thread2");
        Thread thread3 = new Thread(myThread, "thread3");
        Thread thread4 = new Thread(myThread, "thread4");
        Thread thread5 = new Thread(myThread, "thread5");
        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
        thread5.start();
    }
}

运行结果是正确的。synchronized可以修饰方法和代码块。

4.2.2 Lock加锁机制

线程锁也可以通过Lock来实现。代码实例如下(代码D):

package com.moi.test.ThreadTest;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class MyThread implements Runnable{
    private int count = 5;
    Lock lock = new ReentrantLock();
    @Override
    public void run() {
        lock.lock();
        count--;
        System.out.println(Thread.currentThread().getName() + " count:" + count);
        lock.unlock();
    }

    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        Thread thread1 = new Thread(myThread, "thread1");
        Thread thread2 = new Thread(myThread, "thread2");
        Thread thread3 = new Thread(myThread, "thread3");
        Thread thread4 = new Thread(myThread, "thread4");
        Thread thread5 = new Thread(myThread, "thread5");
        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
        thread5.start();
    }
}

运行结果也是正确的,见下图。Lock机制后续在“Java高级”相关博文中会详细介绍。

Java面试题剖析(基础篇) | 第一篇: 线程基础、多线程、线程锁及线程池_第15张图片

4.2.3 对象锁概念

关键字synchronized取得的锁是对象锁,而不是把一段代码或方法当做锁。通过代码实例解释这句话(代码E)。

package com.moi.test.ThreadTest;

public class MultiThread {

    private int num = 200;

    public synchronized void printNum(String threadName, String tag) {
        if (tag.equals("a")) {
            num = num - 100;
            System.out.println(threadName + " tag a,set num over!");
        } else {
            num = num - 200;
            System.out.println(threadName + " tag b,set num over!");
        }
        System.out.println(threadName + " tag " + tag + ", num = " + num);
    }

    public static void main(String[] args) throws InterruptedException {
        final MultiThread multiThread1 = new MultiThread();
        final MultiThread multiThread2 = new MultiThread();

        new Thread(new Runnable() {
            public void run() {
                multiThread1.printNum("thread1", "a");
            }
        }).start();

        new Thread(new Runnable() {
            public void run() {
                multiThread2.printNum("thread2", "b");
            }
        }).start();
    }
}

上面代码中有两个对象:multiThread1和multiThread2。如果多个对象使用同一把锁或者说锁是方法锁的话,那么上述执行的结果就应该是:

thread1 tag a,set num over!
thread1 tag a, num = 100
thread2 tag b,set num over!
thread2 tag b, num = -100

可结果是这样的吗?执行结果见下图:

Java面试题剖析(基础篇) | 第一篇: 线程基础、多线程、线程锁及线程池_第16张图片

可以看出,结果和猜测结果不一致。其实去掉synchronized修饰符,执行结果仍然和上图一致。所以,Java中锁是对象锁,并不是方法锁,一个对象一把锁,多个对象多把锁。

我们在实际开发中,肯定有这么一种情况:所有的对象会对一个变量count进行操作,那么如何实现呢?很简单就是加static关键字。我们知道,用static修饰的方法或者变量,在该类的所有对象是具有相同的引用的,这样的话,无论实例化多少对象,调用的都是一个方法,实例代码如下:

package com.moi.test.ThreadTest;

public class MultiThread {

    private static int num = 200;

    public static void printNum(String threadName, String tag) {
        if (tag.equals("a")) {
            num = num - 100;
            System.out.println(threadName + " tag a,set num over!");
        } else {
            num = num - 200;
            System.out.println(threadName + " tag b,set num over!");
        }
        System.out.println(threadName + " tag " + tag + ", num = " + num);
    }

    public static void main(String[] args) throws InterruptedException {
        final MultiThread multiThread1 = new MultiThread();
        final MultiThread multiThread2 = new MultiThread();

        new Thread(new Runnable() {
            public void run() {
                multiThread1.printNum("thread1", "a");
            }
        }).start();

        Thread.sleep(5000);
        System.out.println("等待5秒,确保thread1已经执行完毕!");

        new Thread(new Runnable() {
            public void run() {
                multiThread2.printNum("thread2", "b");
            }
        }).start();
    }
}

输出结果如下:

Java面试题剖析(基础篇) | 第一篇: 线程基础、多线程、线程锁及线程池_第17张图片

可以看出,对变量和方法都加上了static修饰,就可以实现我们所需要的场景,同时也说明了,对于非静态static修饰的方法或变量,是一个对象一把锁的(后续会针对static、volatile等关键字做剖析)。

以上内容,简要分析了Java中线程基础、多线程、线程锁及线程池等相关知识。同时这些也是Java面试知识点。

欢迎在评论区留言,我会尽快回复~~~

如有任何问题,也可在公众号下留言,工程师们会逐一答疑。公众号二维码:

                                        

 

你可能感兴趣的:(Java面试题剖析,Java面试题剖析,Java多线程,Java面试,Java线程池,Java线程锁)