JDK成长记10:Thread的基本原理和常见应用场景,你都知道么?

file

file

相信你经过集合篇的成长,已经对JDK源码的学习轻车熟路了。接下来你将一起和我进入后半篇的学习。让我们开始吧!

在接下来10分钟,你将学习到thread 的源码原理、线程的状态变化、线程的常用场景。

Thread基础回顾

Thread基础回顾

什么是Thread?

Thread顾名思义,是线程。你应该知道,一个java程序在操作系统上运行,会启动一个JVM进程,具有一个进程ID,这个进程可以创建很多个线程。操作系统、程序、进程、线程关系如下图所示:

file

运行一个线程其实就是启动一个执行分支,执行不同的事情,执行分支时可以是阻塞的也可以是异步的。举一个例子,假如你需要烧开水,还想玩手机。异步就是你烧开水的时候,可以玩手机,阻塞就是你就一直等着水烧开了,再开始玩手机。

创建线程Thread

创建线程的方式一般是2种。一种是继承Thread重写run方法,一种是实现Runnable或Callable接口之后,创建Thread。

当然通过线程池也能说算是一种方式,但是底层还是上面的两种方式之一,没什么区别。

这里带你回顾下,代码如下:

代码清单: LinkedListDemo创建LinkedList

public static void main(String[] args) {
       //创建线程1
      new Thread(()-> System.out.println(Thread.currentThread().getName()),"demo1").start();
       //创建线程2
      new Thread(new MyThread(),"demo2").start();
       //创建线程3
       ThreadFactory namedThreadFactory = new ThreadFactoryBuilder()
.setNameFormat("demo-pool-%d")
.build();
       ExecutorService singleThreadPool = new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(1024),
namedThreadFactory,
new ThreadPoolExecutor.AbortPolicy());
       singleThreadPool.execute(()-> System.out.println(Thread.currentThread().getName()));
       singleThreadPool.shutdown();   
   }

   static class MyThread extends Thread{
       @Override
       public void run() {
          System.out.println(Thread.currentThread().getName());
       }
}

线程Thread的常用方法

相信这些方法你一定很熟悉了,这里就不过多赘述了。我想让你掌握的重点是剖析线程如何在开源项目中的使用,从而更好的理解原理,更好的运用线程,这个在成长记后面会给大家讲解的。

  • start() 启动一个线程

  • join() 加入一个线程

  • sleep() 线程休眠一阵子

  • isAlive() 线程是否存活

  • interupted() 标记线程为中断

  • sinterupted() 线程是否中断

另外还有以下这些方法不经常用,也不建议使用

destroy() 、stop()、 suspend()、 resume()、 yeild()

在开源项目和实际业务系统或者基础架构系统也是很少使用到的。

线程的状态

线程中其实最重要的就是它的状态流转。这里直接给大家看一个图,这个图非常重要,对分析后面的源码有很大帮助。大家一定要牢记于心,线程状态图如下所示:

file

线程应用场景举例

线程应用场景举例

当你回顾了线程的基本知识,这来举几个例子。

在各个开源框架中,线程的应用场景非常广泛,这里给大家举几个例子,主要是为了,让你明白下熟练掌握线程的重要性。之后的相关的成长记你会学到具体是怎么使用线程的细节的。

线程应用举例1 心跳和监控时使用线程

在微服务系统中,经常使用到的一个服务就是注册中心。注册中心简单的讲就是让一个服务访问另一个服务的时候,可以知道对方所有实例的地址,可以用来在调用的时候选择。这就需要每个服务的实例要注册自己的信息到一个公共的地方,这个地方就是注册中心。

每个实例有一个客户端用来和服务端通信,在Spring Cloud实现的微服务技术栈中,Eureka这个组件就充当了注册中心的作用。它的服务端Eureka-Server大量使用了一些Thread。这里我举两个场景,让大家感受下Thread的应用。

一个是场景,每个服务的实例需要发送心跳,告诉注册中心自己还在线,服务还存活着。服务端通过一个线程来判断,某个服务如果在一定时间内不发送心跳了,就认为它故障了,就会把它注册中心剔除掉。如图中蓝色部分的圆圈所示。
file

另一个是场景,Eureka server将每个服务实例注册的信息放到了一个内存map中,为了提高速度和并发能力,它设置了多个缓存,有写缓存和读缓存。Eureka client客户端读取的是读缓存。什么时候将写缓存数据刷入到读缓存?就是通过一个后台线程,每隔30秒从写缓存刷新到读缓存中。

客户端也有一个后台线程,每隔30秒会发起http请求,从服务端读取注册中心的数据。

file

总之,在这个场景中,线程用于了心跳和监控,是非常常见的场景。因为在很多开源项目中都是这样做的,比如hdfs,zookeeper等等。

线程应用举例2 定时更新、保存、删除数据

在MySQL、Redis、ES、HDFS、Zookeeper等等的开源项目中,都会有线程的概念,来定时保存数据,或者刷新磁盘,清理磁盘等等操作。

它们一般其实都很类似,都是保存到磁盘的一个就是操作日志,一个就是快照数据。

比如hdfs中的checkpoint线程,用来自动合并edit_log为fsImage、自动清除edit_log线程、比如mysql定时刷入bufferPool的数据到磁盘中的线程。比如Zookeeper保存WAL日志、或者mysql定时保存dump文件,redis的快照等等。

线程应用举例3 多线程提高处理速度和性能

比较典型的就是Storm、Flink、Spark分布式计算就是使用多线程计算提高性能,当然还有MQ的多线程消费,也是典型的多线程提高了处理性能。

这里不就不一一举例了。有兴趣的同学可以在评论区里写下自己知道的开源项目中如何使用线程。

Thread源码剖析

Thread源码剖析

回顾了线程的基本知识使用场景,接下来你需要了解下Thread的源码,才能更好的理解它,使用它。

下面主要通过创建线程,启动线程,线程状态变化操作这几个场景简单摸一下Thread的源码。

第一个场景:创建线程

newThread(

   ()-> System.out.println(Thread.currentThread().getName()),"demo1"

).start();

让我们分析一下这行代码,首先是new Thread,会进入构造函数。

public Thread(Runnabletarget) {
 
   init(null, target,"Thread-"+ nextThreadNum(), 0);
 
}

调用了init方法前,调用了nextThreadNum方法,生成了一个自增id,和Thread-+num 作为线程名字。

/* For autonumbering anonymous threads. */

private static intthreadInitNumber;

private static synchronized int nextThreadNum() {

  return threadInitNumber++;

}

上面源码总结为下图:

file

默认生成线程名字之后,调用4个参数的init方法,接着又继续调用了6个参数的init方法。

private void init(ThreadGroupg, Runnable target,String name,
 
         long stackSize) {
 
   init(g, target, name, stackSize,null, true);
 
 }

这个init是创建线程核心的步骤我们来看下它的脉络。

private void init(ThreadGroupg, Runnable target,String name,
 
         long stackSize, AccessControlContextacc,
 
         booleaninheritThreadLocals) {
 
   if(name ==null) {
 
     throw newNullPointerException("name cannot be null");
 
   }
 
 
 
   this.name = name;
 
 
 
   Thread parent= currentThread();
 
   SecurityManager security= System.getSecurityManager();
 
   if(g ==null) {
 
     if(security !=null) {
 
       g = security.getThreadGroup();
 
     }
 
     if(g ==null) {
 
       g = parent.getThreadGroup();
 
     }
 
   }
 
   g.checkAccess();
 
   if(security !=null) {
 
     if(isCCLOverridden(getClass())) {
 
       security.checkPermission(SUBCLASS_IMPLEMENTATION_PERMISSION);
 
     }
 
   }
 
 
 
   g.addUnstarted();
 
 
 
   this.group = g;
 
   this.daemon =parent.isDaemon();
 
   this.priority =parent.getPriority();
 
   if(security ==null || isCCLOverridden(parent.getClass()))
 
     this.contextClassLoader= parent.getContextClassLoader();
 
   else
 
     this.contextClassLoader=parent.contextClassLoader;
 
  this.inheritedAccessControlContext=
 
       acc !=null ? acc : AccessController.getContext();
 
   this.target = target;
 
   setPriority(priority);
 
   if(inheritThreadLocals && parent.inheritableThreadLocals!= null)
 
     this.inheritableThreadLocals=
 
       ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
 
   /* Stash the specified stack size in case the VM cares */
 
   this.stackSize = stackSize;
 
 
 
   /* Set thread ID */
 
   tid= nextThreadID();
 
 }

上面的代码从脉络可以分为4点:

  1. 设置线程名称,默认是Thread+全局自增id
  2. 设置线程组 父线程默认是当前线程,默认线程所属组为父线程组
  3. 优先级和是否是后台线程 默认和父线程相同
  4. 保存传入的Runnable或Callable的run方法

如下图所示:

file

这个线程创建的过程,你需要记住的核心点就是以下几点:

创建你的线程,就是你的父线程

如果你没有指定ThreadGroup,你的ThreadGroup就是父线程的ThreadGroup

你的daemon状态默认是父线程的daemon状态

你的优先级默认是父线程的优先级

如果你没有指定线程的名称,那么默认就是Thread-0格式的名称

你的线程id是全局递增的,从1开始

第二个场景:启动线程

当创建了线程后,接着就是启动线程了。你一定知道,thread启动一个线程使用通过start方法。它的底层逻辑很简单,代码如下:

public synchronized void start() {
  if(threadStatus !=0)
    throw newIllegalThreadStateException();

  group.add(this);

  boolean started =false;

  try{
    start0();
    started =true;
  } finally{
    try{
      if(!started) {
        group.threadStartFailed(this);
      }
    } catch(Throwable ignore) {


    }
  }
}

还是来看下核心脉络:

1、判断线程状态,不能重复启动

2、放入线程组

3、调用一个native方法启动线程,如果失败从线程组中移除。

这个navive方法是C++实现的,具体我们不去研究了,你只要知道本质是将线程交给CPU线程执行调度器执行,之后CPU会通过时间片的算法来执行各个线程,就可以了。如下图所示:

file

启动完成后,肯定会调用run方法,这里的run方法就是之前构造函数保存的Runnable动的run方法。如果是继承Thread方式创建的线程,这个run方法被重写了,所以下面这段逻辑只是适用于Runnalbe或Callable方式创建的Thread。

@Override
 public void run() {
   if(target !=null) {
     target.run();
   }
 }

第三个场景:线程状态变化操作

了解了创建线程和不知道你还记得之前的线程状态图么?怎么进入到其他状态呢?让我们一起来看看状态变化的一些线程操作。

一个线程创建时候是NEW启动后变为Runnable。之后怎么变为WAITING呢?其实有很多种方法,我们这里讲一个常用的方法join()。当然调用Obejct.wait()或LockSupport.part()操作也会实现同样的效果,这个我们的成长记后面会讲到,大家不要着急。

顾名思义,join表示加入的意思。意思就是另一线程加入到执行过程中,当前线程需要等待加入的线程执行完成才能继续执行。如下图所示:

file

接着我们看下如何进入TimeWaiting状态呢?其实也很简单。你可以通过使用sleep方法来进入这个状态。sleep方法支持传入等待时间。一般有两种方式一个是直接传入毫秒值比如 60 * 1000表示60秒,一个是可以通过TimeUnit这个工具传递时间。但是大多数开源项目为了灵活,使用的都是第一种方式,因为第二种方式限制了单位,如果需要修改的话,使用起来就不太灵活了。使用sleep的流程如下:

file

如果你看sleep的源码,发现就是一个native方法,底层肯定是通过C++代码,通知CPU线程休眠一阵子。无论在Java线程模型还是CPU的线程模型,其实线程状态的变化都是一样的,其实线程的状态的模型是一个抽象概念。

最后线程如何进入Block状态?其实是通过syncronized加锁后,被阻塞的线程就会进入block状态。后面讲到syncronized的时候详细分析。这里就不过多说明了。

好了到这里,我们通过线程的核心的三个场景分析了thread的源码原理。相信你对thread有了进一步的认识了,但是更重要的是,你要不断的积累使用thread的场景,在合适的使用thread的方法。理解线程的状态变化就显得更为重要。

本文由博客群发一文多发等运营工具平台 OpenWrite 发布

posted @ 2021-10-19 12:00  _繁茂  阅读(126)  评论(0编辑  收藏  举报