并发编程系列---2、线程和线程池

线程池在并发编程中使用很普遍,而且线程池的原理很比较容易懂,但是这个不管是面试还是工作中都还是很重要的。下面我们主要来具体讲一下线程池、线程这些知识,汇总下,大家看这一篇文章我认为就够用了。里面有各种面试会问的,看懂了,线程池这块就拿捏的死死的了。

目录

一、多线程

二、线程池

2.1、线程池目的

2.2、线程池基本框架

2.3、线程池状态

2.4、线程池 excute 分析

2.5、自定义线程池

2.6、线程池核心线程数怎么设置

三、线程间通信方式

3.1、进程间通信方式

3.2、线程间通信方式


 

一、多线程

多线程的原理简单描述如下:

相当于玩游戏机,只有一个游戏机(cpu),可是有很多人要玩,于是,start是排队!等CPU选中你就是轮到你,你就run(),当CPU的运行的时间片执行完,这个线程就继续排队,等待下一次的run()。

调用start()后,线程会被放到等待队列,等待CPU调度,并不一定要马上开始执行,只是将这个线程置于可运行状态。然后通过JVM,线程Thread会调用run()方法,执行本线程的线程体。先调用start后调用run,这么麻烦,为了不直接调用run?就是为了实现多线程的优点,没这个start还不行。请耐心往下看具体原因。

JAVA多线程实现方式主要有以下三种: 

1、继承Thread类,重写run方法。用start方法启动线程

2、实现Runnable接口,实现run方法。用new Thread(Runnable target).start()方法来启动

3、使用ExecutorService、Callable、Future实现有返回结果的多线程。

其中前两种方式线程执行完后都没有返回值,只有最后一种是带返回值的在异步中用的较多。其中最常用的也是前两种实现方式。

我们看下用start和run启动的区别,代码示例:

public class ThreadTest {
    public static void main(String[] args) {
        Runner1 runner1 = new Runner1();
        Runner2 runner2 = new Runner2();
        // Thread(Runnable target) 分配新的 Thread 对象。
        Thread thread1 = new Thread(runner1);
        Thread thread2 = new Thread(runner2);
        thread1.start(); //执行start,thread1与thread2交叉执行
        thread2.start();
        //thread1.run(); //执行run,thread1与thread2顺序执行
        //thread2.run();
    }
}

class Runner1 implements Runnable { // 实现了Runnable接口,jdk就知道这个类是一个线程
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println("进入Runner1运行状态——————————" + i);
        }
    }
}

class Runner2 implements Runnable { // 实现了Runnable接口,jdk就知道这个类是一个线程
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println("进入Runner2运行状态==========" + i);
        }
    }
}

 

大家运行下就会发现thread.run()是串行化的。这就是区别。

1.start()方法来启动线程,真正实现了多线程运行。这时无需等待run方法体代码执行完毕,可以直接继续执行下面的代码;通过调用Thread类的start()方法来启动一个线程, 这时此线程是处于就绪状态, 并没有运行。 然后通过此Thread类调用方法run()来完成其运行操作的, 这里方法run()称为线程体,它包含了要执行的这个线程的内容, Run方法运行结束, 此线程终止。然后CPU再调度其它线程。

(多线程同时运行)

2.run()方法当作普通方法的方式调用。程序还是要顺序执行,要等待run方法体执行完毕后,才可继续执行下面的代码; 程序中只有主线程——这一个线程, 其程序执行路径还是只有一条, 这样就没有达到多线程的目的。

(相当于顺序运行)

多线程就是分时利用CPU,宏观上让所有线程一起执行 ,也叫并发。这下就知道为啥没有start不行了吧。

 

二、线程池

2.1、线程池目的

在并发编程中,多线程运行的时候如果我们使用Thread[] thread = new Thread[100] 这种方式来创建多线程,然后执行,不仅代码写的繁琐,而且线程之间的唤醒,阻塞,还得需要自己去用代码控制,很是麻烦。相较于这种new出来线程,使用线程池管理线程就有了很大的优势。

1)减少系统维护线程的开销;

2)解耦 : 可以使运行和创建分开;

3)线程复用;

使用线程池主要目的就是能够线程复用,减少系统对线程创建,销毁,维护的开销。

线程池的基本框架是Excutor,使用ThreadPoolExcutor

线程池的核心参数:

CorePoolSize  核心线程大小 与pool的生命周期相同

maximumPoolSize 最大线程数量

keepAliveTime 线程保持活动的时间,为了设定maximumPoolSize配置的线程的生命周期,一旦keepalivetime时间内没有任务就会销毁

TimeUnit : 时间单位

BlockingQueue : 任务阻塞队列

DefaultHandler : 拒绝策略

 

2.2、线程池基本框架

1、先判断线程池中核心线程池(corePoolSize)所有的线程是否都在执行任务。如果不是,则新创建一个线程执行刚提交的任务,否则,核心线程池中所有的线程都在执行任务,则进入第2步;

2、判断当前阻塞队列是否已满,如果未满,则将提交的任务放置在阻塞队列中;否则,则进入第3步;

3、判断线程池中所有的线程(maximumPoolSize)是否都在执行任务,如果没有,则创建一个新的线程来执行任务,否则,则交给饱和策略进行处理

 

如下图所示:

并发编程系列---2、线程和线程池_第1张图片

 

2次询问线程池的时候,线程数的判断标准是不一样的,一个是核心线程数,一个是最大线程数。这点要注意下。

拒绝策略有四种我们附上源码:

  • AbortPolicy (中止) 
  • 并发编程系列---2、线程和线程池_第2张图片

     

会抛出未检查的RejectedExecutionException,调用者可以捕获这个异常,然后根据需求编写自己的处理代码;在业务中可以去捕获这个异常,然后进行一些业务的处理,比如返回默认的接口返回数据。

  • CallerRunsPolicy (调用者运行

    并发编程系列---2、线程和线程池_第3张图片

     

该策略既不会抛弃任务,也不会抛出异常,而是当线程池中的所有线程都被占用后,并且工作队列被填满后,下一个任务会在调用execute时在主线程中执行,从而降低新任务的流量。由于执行任务需要一定的时间,因此主线程至少在一定的时间内不能提交任何任务,从而使得工作者线程有时间来处理正在执行的任务。

另一方面,在这期间,主线程不会调用accept,那么到达的请求将被保存在TCP层的队列中而不是在应用程序的队列中。如果持续过载,那么TCP层将最终发现他的请求队列被填满,因此同样会开始抛弃请求。

当服务器过载时,这种过载情况会逐渐向外蔓延开来——从线程池到工作队列到应用程序再到TCP层,最终到达客户端,导致服务器在高负载的情况下实现一种平缓的性能降低。

  • DiscardOldestPolicy  (抛弃最旧) 

    并发编程系列---2、线程和线程池_第4张图片

     

抛弃队列中最老的任务,然后重新提交最新的任务排到队尾。这个策略千万不要跟优先级队列配合使用。否则会把优先级最高的给扔了。

  • DiscardPolicy (抛弃) 

    并发编程系列---2、线程和线程池_第5张图片

     

如果线程池队列满了,会直接丢掉这个任务并且不会有任何异常。当提交的任务无法保存到队列中等待执行时,Discard策略会悄悄抛弃该任务。

  • 自定义异常:

当然如果大家想统一处理异常,可以自定义一个异常处理类。然后继承RejectedExecutionHandler。统一处理异常。

 

并发编程系列---2、线程和线程池_第6张图片

 

2.3、线程池状态

了解的线程池里面的状态控制的参数 ctl。

线程池的ctl是一个原子的 AtomicInteger。

这个ctl包含两个参数 :

  • workerCount 激活的线程数
  • runState 当前线程池的状态
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int CAPACITY   = (1 << COUNT_BITS) - 1;

// runState is stored in the high-order bits
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;

ctl是32位,它的低29位用于存放当前的线程数, workerCount的理论最大值就应该是29个1,理论上最大的线程数是 536870911;

CAPACITY的运算过程为1左移29位,也就是00000000 00000000 00000000 00000001 --> 001 0000 00000000 00000000 00000000,再减去1的话,就是 000 11111 11111111 11111111 11111111,前三位代表线程池运行状态runState

其中高三位的值和状态对应如下:

  • 111: RUNNING (-1左移29,高位补1,所以高三位111,以下依次类推)

说明:运行状态,线程池创建后就处于Running

      切换:一进入就是运行状态

  • 000: SHUTDOWN

说明:关闭,不接受新任务,但是已经队列中的任务需要处理完

切换:pool 执行 showdown的时候

  • 001: STOP

说明:不接受新任务,也不执行队列中的任务

   切换:shutdownnow() 方法执行的时候

  • 010: TIDYING
  • 110: TERMINATED

五种状态转换一图搞定,都是心血总结啊。

并发编程系列---2、线程和线程池_第7张图片

 

ctl提供了相关api:都是位运算,

 // Packing and unpacking ctl
    // 获取线程池的状态
    private static int runStateOf(int c)     { return c & ~CAPACITY; }
    // 获取线程池的工作线程数
    private static int workerCountOf(int c)  { return c & CAPACITY; }
    // 根据工作线程数和线程池状态获取 ctl
    private static int ctlOf(int rs, int wc) { return rs | wc; }

 

2.4、线程池 excute 分析

 

我们将源码贴在下面便于分析。看这段代码的时候,千万不要陷进去,因为特别绕。站在大体角度了解下就可以了。代码里面我加了注释

public void execute(Runnable command) {
    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.
     *
     * 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.
     */
     //获取线程池的状态码
    int c = ctl.get();
    //如果线程池的工作线程个数少于corePoolSize则创建新线程执行当前任务
    if (workerCountOf(c) < corePoolSize) {
        //执行addWork,提交为核心线程,提交成功return。提交失败重新获取ctl
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
    //如果线程个数大于corePoolSize则检查线程池状态是否是正在运行,且将新线程向阻塞队列提交。
    if (isRunning(c) && workQueue.offer(command)) {
        //recheck 需要再次检查,主要目的是判断加入到阻塞队里中的线程是否可以被执行
        int recheck = ctl.get();
        //如果线程池状态不为running,将任务从阻塞队列里面移除,启用拒绝策略
        if (! isRunning(recheck) && remove(command))
            reject(command);
        // 如果线程池的工作线程为零,则调用addWoker提交任务    
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
    //如果当前任务无法放进阻塞队列中,则创建新的线程来执行任务,如果创建失败则拒绝
    else if (!addWorker(command, false))
        reject(command);
}

execute方法执行逻辑有这样几种情况:

  1. 如果当前运行的线程,少于corePoolSize,则创建一个新的线程来执行任务。
  2. 如果运行的线程等于或多于 corePoolSize,将任务加入 BlockingQueue。
  3. 如果加入 BlockingQueue 成功,需要二次检查线程池的状态如果线程池没有处于 Running,则从 BlockingQueue 移除任务,启动拒绝策略。
  4. 如果线程池处于 Running状态,则检查工作线程(worker)是否为0。如果为0,则创建新的线程来处理任务。如果启动线程数大于maximumPoolSize,任务将被拒绝策略拒绝。
  5. 如果加入 BlockingQueue 。失败,则创建新的线程来处理任务。
  6. 如果启动线程数大于maximumPoolSize,任务将被拒绝策略拒绝。

 

2.5、自定义线程池

 

* 1. SimpleAsyncTaskExecutor:不是真的线程池,这个类不重用线程,每次调用都会创建一个新的线程(默认)。

* 2. SyncTaskExecutor:这个类没有实现异步调用,只是一个同步操作。只适用于不需要多线程的地方

* 3. ConcurrentTaskExecutor:Executor的适配类,不推荐使用。如果ThreadPoolTaskExecutor不满足要求时,才用考虑使用这个类

* 4. SimpleThreadPoolTaskExecutor:是Quartz的SimpleThreadPool的类。线程池同时被quartz和非quartz使用,才需要使用此类

* 5. ThreadPoolTaskExecutor :最常使用,推荐。 其实质是对java.util.concurrent.ThreadPoolExecutor的包装

 

2.6、线程池核心线程数怎么设置

一般说来,大家认为线程池的大小经验值应该这样设置:(其中N为CPU的个数)

* 如果是CPU密集型应用,则线程池大小设置为N+1

* 如果是IO密集型应用,则线程池大小设置为2N+1

某些进程花费了绝大多数时间在计算上,而其他则在等待I/O上花费了大多是时间,前者称为计算密集型(CPU密集型),后者称为I/O密集型

如果一台服务器上只部署这一个应用并且只有这一个线程池,那么这种估算或许合理,具体还需自行测试验证。

但是,IO优化中,这样的估算公式可能更适合:

最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目

因为很显然,线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。

*下面举个例子: 比如平均每个线程CPU运行时间为0.5s,而线程等待时间(非CPU运行时间,比如IO)为1.5s,CPU核心数为8,那么根据上面这个公式估算得到:((0.5+1.5)/0.5)*8=32。这个公式进一步转化为: **最佳线程数目 = (线程等待时间与线程CPU时间之比 + 1)* CPU数目

刚刚说到的线程池大小的经验值,其实是这种公式的一种估算值。

到这里,线程和线程池的主要内容就讲完了,应该大家没啥问题了。面试绝对面试官问不出什么新花样了。

三、线程间通信方式

下面在根据面试问题,给大家介绍下线程、进程间的通信方式。

3.1、进程间通信方式

管道( pipe ):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。

有名管道 (namedpipe) : 有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。

信号量(semophore ) : 信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。

消息队列( messagequeue ) : 消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。

信号 (sinal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。

共享内存(shared memory ) :共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号两,配合使用,来实现进程间的同步和通信。

套接字(socket ) : 套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同及其间的进程通信。

 

3.2、线程间通信方式

锁机制:包括互斥锁、条件变量、读写锁,比如volatle就是靠着cache line进行通信的

             互斥锁提供了以排他方式防止数据结构被并发修改的方法。

            读写锁允许多个线程同时读共享数据,而对写操作是互斥的。

             条件变量可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变                               量始终与互斥锁一起使用。

信号量机制(Semaphore):包括无名线程信号量和命名线程信号量

信号机制(Signal):类似进程间的信号处理

线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。

 

 

 

你可能感兴趣的:(并发编程深入原理的文章,多线程,java,面试,并发编程)