QQ登录

只需要一步,快速开始

APP扫码登录

只需要一步,快速开始

查看: 3319|回复: 0

[JAVA/JSP] 线程池的正确使用姿势

[复制链接]

等级头衔

积分成就    金币 : 2861
   泡泡 : 1516
   精华 : 6
   在线时间 : 1323 小时
   最后登录 : 2026-3-2

丰功伟绩

优秀达人突出贡献荣誉管理论坛元老活跃会员

联系方式
发表于 2023-9-4 11:50:31 | 显示全部楼层 |阅读模式
在Java语言中,创建线程并不像创建对象一样简单。虽然只需要使用new Thread()即可创建线程,但实际上创建线程比创建对象复杂得多。创建对象只需在JVM的堆中分配内存,而创建线程需要调用操作系统内核的API,并为线程分配一系列资源,这个成本相对较高。因此,线程被视为重量级的对象,应尽量避免频繁创建和销毁。4 d3 B" B1 X5 Q: D
1.jpg
* U* q1 d' v: N* O' @1 C6 ^# _% }6 E  B7 K那么如何避免频繁创建线程呢?解决方案就是使用线程池。, b( Z! h, q. J0 z  _, O$ h
由于线程池的需求非常普遍,所以Java SDK的并发包自然也包含了线程池。但是,很多人初次接触并发包中与线程池相关的工具类时,可能会感到有些困惑,不知从何入手。我认为,这主要是因为线程池与通常意义上的资源池是不同的。一般意义上的资源池在需要资源时调用acquire()方法申请资源,在使用完毕后调用release()释放资源。然而,如果你带着这种固有模型来看待并发包中的线程池相关工具类,会遗憾地发现它们与之不匹配,因为Java提供的线程池中根本不存在申请线程和释放线程的方法。
+ N* L6 E0 O+ h. m/ ]7 |
class XXXPool{
  // 获取池化资源
  XXX acquire() {
  }
  // 释放池化资源
  void release(XXX x){
  }
}
线程池是一种生产者-消费者模式# l8 Q* I$ u, _& M
线程池之所以没有采用一般意义上池化资源的设计方法,是因为线程池是基于生产者-消费者模式的设计。! p- G' q+ y( j6 X0 D* ~" n4 ~
在一般意义上的池化资源设计中,我们可以通过acquire()方法获取到一个空闲资源,然后通过使用资源来执行具体的任务,最后再通过release()方法释放资源。但是在线程池中,由于涉及到线程的管理和复用,采用了不同的设计思路。
: {, x. [. c7 x5 V$ X/ m# A( A& G7 n当我们从线程池中获取到一个空闲线程时,我们期望能够像使用Thread类创建线程那样,通过调用该线程的execute()方法,传入一个Runnable对象来执行具体的业务逻辑。然而,遗憾的是,Thread类并没有像execute(Runnable target)这样的公共方法。这是因为线程池在管理线程时,需要考虑到线程的状态、任务队列等方面的复杂情况,因此不能简单地将线程的使用方式与传统的池化资源相同。/ C' L+ S4 J- v, [$ K3 z  n2 M' O
//采用一般意义上池化资源的设计方法
class ThreadPool{
  // 获取空闲线程
  Thread acquire() {
  }
  // 释放线程
  void release(Thread t){
  }
}
//期望的使用
ThreadPool pool;
Thread T1=pool.acquire();
//传入Runnable对象
T1.execute(()->{
  //具体业务逻辑
  ......
});
所以,线程池的设计,没有办法直接采用一般意义上池化资源的设计方法。那线程池该如何设计呢?目前业界线程池的设计,普遍采用的都是 生产者-消费者模式。线程池的使用方是生产者,线程池本身是消费者。在下面的示例代码中,我们创建了一个非常简单的线程池MyThreadPool,你可以通过它来理解线程池的工作原理。6 i4 K; j3 i+ x* n4 R& ]: S# B
//简化的线程池,仅用来说明工作原理
class MyThreadPool{
  //利用阻塞队列实现生产者-消费者模式
  BlockingQueue<Runnable> workQueue;
  //保存内部工作线程
  List<WorkerThread> threads
    = new ArrayList<>();
  // 构造方法
  MyThreadPool(int poolSize,
    BlockingQueue<Runnable> workQueue){
    this.workQueue = workQueue;
    // 创建工作线程
    for(int idx=0; idx<poolSize; idx++){
      WorkerThread work = new WorkerThread();
      work.start();
      threads.add(work);
    }
  }
  // 提交任务
  void execute(Runnable command){
    workQueue.put(command);
  }
  // 工作线程负责消费任务,并执行任务
  class WorkerThread extends Thread{
    public void run() {
      //循环取任务并执行
      while(true){ ①
        Runnable task = workQueue.take();
        task.run();
      }
    }
  }
}

/** 下面是使用示例 **/
// 创建有界阻塞队列
BlockingQueue<Runnable> workQueue =
  new LinkedBlockingQueue<>(2);
// 创建线程池
MyThreadPool pool = new MyThreadPool(
  10, workQueue);
// 提交任务
pool.execute(()->{
    System.out.println("hello");
});
在MyThreadPool的内部,我们维护了一个阻塞队列workQueue和一组工作线程,工作线程的个数由构造函数中的poolSize来指定。用户通过调用execute()方法来提交Runnable任务,execute()方法的内部实现仅仅是将任务加入到workQueue中。MyThreadPool内部维护的工作线程会消费workQueue中的任务并执行任务,相关的代码就是代码①处的while循环。线程池主要的工作原理就这些,是不是还挺简单的?
9 k& {3 \! A/ A8 C8 B- U: v6 l如何使用Java中的线程池
- E  I) @4 V+ o9 h3 l7 ^2 wJava并发包里提供的线程池,远比我们上面的示例代码强大得多,当然也复杂得多。Java提供的线程池相关的工具类中,最核心的是 ThreadPoolExecutor,通过名字你也能看出来,它强调的是Executor,而不是一般意义上的池化资源。
& s4 E2 G: c5 I7 I% q& G6 uThreadPoolExecutor的构造函数非常复杂,如下面代码所示,这个最完备的构造函数有7个参数。
5 s) J1 k# `& E3 p
ThreadPoolExecutor(
  int corePoolSize,
  int maximumPoolSize,
  long keepAliveTime,
  TimeUnit unit,
  BlockingQueue<Runnable> workQueue,
  ThreadFactory threadFactory,
  RejectedExecutionHandler handler)
下面我们逐一介绍这些参数的含义,将线程池类比为一个项目组,而每个线程就是项目组的成员。
$ f9 V, H; R9 |2 t5 @$ b4 X3 H" n9 K
  • corePoolSize:表示线程池所持有的最小线程数。有些项目可能很闲,但也不能将所有项目组成员都撤离,至少要留下corePoolSize个人守在岗位上。
  • maximumPoolSize:表示线程池可创建的最大线程数。当项目繁忙时,需要增加成员,但也不能无限制地增加,最多增加到maximumPoolSize个人。当项目变得闲暇时,需要减少成员,最多将成员减至corePoolSize个人。
  • keepAliveTime & unit:前面提到,项目根据忙闲来增减成员。在编程世界中,如何定义忙和闲呢?很简单,如果一个线程在一段时间内都没有执行任务,说明它处于闲置状态。而keepAliveTime和unit就用来定义这段时间的参数。也就是说,如果一个线程空闲了keepAliveTime & unit这么长时间,并且线程池的线程数超过了corePoolSize,那么该空闲线程就会被回收。
  • workQueue:工作队列,与上面示例代码中的工作队列意义相同。
  • threadFactory:通过该参数,你可以自定义如何创建线程。例如,你可以为线程指定一个有意义的名称。
  • handler:通过该参数,你可以自定义任务的拒绝策略。如果线程池中所有的线程都在忙碌,且工作队列已满(前提是工作队列是有界队列),此时提交任务,线程池将会拒绝接收。至于拒绝策略,你可以通过handler参数来指定。ThreadPoolExecutor已经提供了以下四种策略:
  • CallerRunsPolicy:提交任务的线程自行执行该任务。
  • AbortPolicy:默认的拒绝策略,会抛出RejectedExecutionException异常。
  • DiscardPolicy:直接丢弃任务,没有任何异常抛出。
  • DiscardOldestPolicy:丢弃最老的任务,实际上是丢弃最早进入工作队列的任务,并将新任务加入工作队列。
    " _/ p1 [9 {* x: s" h
Java 1.6版本还增加了allowCoreThreadTimeOut(boolean value)方法,它可以使所有线程都支持超时。这意味着如果项目很闲,项目组的成员都会被撤离。! O- C; r9 Z: W# ?( E
使用线程池要注意些什么
" Y# p+ Y8 W6 o0 {考虑到ThreadPoolExecutor的构造函数相对复杂,Java并发包提供了一个线程池的静态工厂类Executors,通过Executors可以快速创建线程池。不过,目前大型公司的编码规范一般不建议使用Executors,所以我就不再详细介绍这方面内容了。* y4 M9 K" t6 x" p
不建议使用Executors最重要的原因是:Executors提供的许多方法默认使用**的LinkedBlockingQueue。在高负载情况下,**队列很容易导致OOM(内存溢出),而OOM会导致所有请求都无法处理,这是一个严重的问题。因此,强烈建议使用有界队列。
" T/ O/ o8 `( Y0 q# l0 [$ t/ D使用有界队列时,当任务过多时,线程池会触发执行拒绝策略。线程池的默认拒绝策略会抛出RejectedExecutionException异常,这是一个运行时异常,编译器不会强制要求捕获它,因此开发人员很容易忽略。因此,在使用默认拒绝策略时要谨慎。如果线程池处理的任务非常重要,建议自定义拒绝策略,并在实际工作中将自定义的拒绝策略与降级策略配合使用。
, t$ f% p$ e" T1 }# n0 w在使用线程池时,还需要注意异常处理的问题。例如,通过ThreadPoolExecutor对象的execute()方法提交任务时,如果任务在执行过程中出现运行时异常,会导致执行该任务的线程终止。然而,最致命的是尽管任务发生异常,你却无法获得任何通知,这可能让你误以为任务都正常执行了。尽管线程池提供了许多用于异常处理的方法,但最可靠和简单的方案是捕获所有异常并根据需要进行处理,你可以参考下面的示例代码。: j! L' e8 t" X4 F  C9 m! w' i
try {
  //业务逻辑
} catch (RuntimeException x) {
  //按需处理
} catch (Throwable x) {
  //按需处理
}
总结
0 v: s! H% Z7 v" V" D0 I/ G线程池在Java并发编程领域中扮演着重要角色,许多大型公司的编码规范要求使用线程池来管理线程。线程池与普通的资源池有很大的区别,实际上它是生产者-消费者模式的一种实现。理解生产者-消费者模式是理解线程池的关键所在。
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

QQ|手机版|小黑屋|paopaomj.COM ( 渝ICP备18007172号|渝公网安备50010502503914号 )

GMT+8, 2026-3-8 06:49

Powered by paopaomj X3.5 © 2016-2025 sitemap

快速回复 返回顶部 返回列表