Java--线程
线程
线程(Thread)是操作系统中能够调度的最小单位,被包含在进程之中,是进程的实际运作单位,一条线程是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
总体来说,就操作系统层面,线程分为两种:
- 用户级线程:操作系统内核不知道应用线程的存在,管理依赖于内核线程。
- 内核级线程:它们是依赖于内核的,即无论是用户进程中的线程,还是系统进程中的线程,它们的创建、撤消、切换都由内核实现。
产生原因
60年代,在OS中能拥有资源和独立运行的基本单位是进程,然而随着计算机技术的发展,进程出现了很多弊端:
- 由于进程是资源拥有者,创建、撤消与切换存在较大的时空开销,因此需要引入轻型进程
- 由于对称多处理机(SMP)出现,可以满足多个运行单位,而多个进程并行开销过大。
为了解决因进程导致的时间和空间问题,引入了线程。
线程带来的好处
- 它是轻型实体,基本上不拥有系统资源,但是它有确保独立运行的资源
- 独立调度和分派的基本单位,并且由于其“轻”量化的特性,切换相对比较迅速,开销较小,一般都是在同一个进程中切换
- 可并发执行,一个进程中的多个线程可以并发执行,甚至允许在一个进程中所有线程都能并发执行;同样,不同进程中的线程也能并发执行
- 共享进程资源,前提是都在同一个进程中的所有线程都可共享该进程所拥有的资源,具体表现为:所有线程都具有相同的地址空间(进程的地址空间)、还可以访问进程所拥有的已打开文件、定时器、信号量机构等以及线程间的相互通讯不必调用内核。
Java中的线程
在JDK1.2之前采用的是一种叫做“绿色线程”的用户级线程实现的,从1.2开始,采用的都是基于操作系统的原生线程模型来实现的,只是会根据不同的操作系统,Java线程和操作系统线程有不同的映射关系。
在目前的HotSpot虚拟机中,线程模型与操作系统的模型采用的是1:1模型(windows和linux,一条Java线程就对应着操作系统上一个轻量级线程),但是在Solaris操作系统上有所不同,它不仅支持1:1,还支持M:N,即混合型线程,可以通过运行参数指定。所谓的混合型线程,说的就是:
image.png线程创建完全在用户空间中完成,线程的调度和同步也在应用程序中进行。一个应用程序中的多个用户级线程被映射到一些内核级线程上。
Java中线程的状态
这里直接参考
Thread
源码中的内部枚举类State
,它内部定义了六种常量,用以表示Java中线程可能会处于的状态。
- NEW:创建但未启动
- RUNNABLE:在JVM中正在运行的线程,但是此时它也有可能正在等待其他资源(如:操作系统的处理器)
- BLOCKED:阻塞状态,等待监视器锁定,一般等待监视器锁指的就是:进入一个synchronized代码块或方法,或者是重入一个synchronized代码块或方法
- WAITING:调用一些方法时会使线程处于等待状态
- TIMED_WAITING:它的触发条件就是基于前面的WAITING条件,只是多了时间参数传入
- TERMINATED:终止状态,它一般都是在线程执行任务完成后进入的状态
RUNNABLE
Thread
中有一个yield
方法,它的作用就是让线程重新回到Runnable的状态,以允许具有相同优先级的其他线程获得运行机会。因此,使用yield()
的目的是让相同优先级的线程之间能适当的轮转执行。但是,实际中无法保证yield()
达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。
WAITING状态
注释上解释说:它是在调用
image.pngObject
类中的wait
方法,并且还未超时线程所处的状态,默认wait
无参方法timeout为0。或者调用LockSupport类中的park方法。
一般处于WAITING状态的线程都是在等待另一个线程的特定动作,比如:notify或notifyAll,而对于join这种方法,则是在等待特定线程中断。
TIMED_WAITING状态
- 调用
Object
类中wait(long timeout)
方法 - 调用
Thread
方法中的join(long timeout)
方法 - 调用
LockSupport
类中的parkNanos
- 调用
LockSupport
类中的parkUntil
方法
基本上就是对WAITING状态的一个扩充,多了timeout参数用以指定时间。
Java中线程的使用
继承Thread类
public class ThreadWay extends Thread {
@Override
public void run() {
System.out.println("继承Thread类");
}
public static void main(String[] args) {
ThreadWay threadWay = new ThreadWay();
threadWay.start();
}
}
这是最为古老的做法,使用起来也非常方便,重写run
方法,启动线程调用它的start
方法。但是这种写法有一个非常大的缺陷就是不够灵活,Java中都是单继承,所以如果一个类需要既继承别的业务类,同时还要通过继承Thread
方式实现多线程,就必须得考虑使用多级继承,最终会导致代码变得复杂臃肿,类之间的关系难以维护。
实现Runnable接口
public class RunnableWay implements Runnable {
@Override
public void run() {
System.out.println("实现Runnable接口创建线程");
}
public static void main(String[] args) {
RunnableWay runnableWay = new RunnableWay();
Thread t = new Thread(runnableWay);
t.start();
//其实只要传入Thread中一个Runnable实例,Runnable实例就是赋值给Thread内部的target对象
//Thread内部的run方法其实就是判断target是否为空,如果不为空,它就会调用target的run方法
}
}
因为Thread
存在的缺陷,所以在有些场景下,可以采用实现Runnable
接口的方式,众所周知,Java中一个类可以同时实现多个接口,从而变相达到多继承的效果。
但它的使用还是最终依托于Thread
类,将实现了Runnable
的类实例化作为参数传入Thread构造方法中,构建Thread
对象,后面的调用就跟Thread
方法一模一样了。
但是仍然有一些情况Thread和Runnable都无法达到预定的效果,回头看上面的示例代码中,我们重写的run方法是没有返回值的,也就是说一旦启动了线程之后,我们在外部无法获取线程执行的返回结果,还得考虑使用一些额外的手段才能达到目的,所以这也就引出了Callable
接口的意义。
实现Callable接口
它在Java中定义很简单,内部只有一个方法叫call:
public interface Callable<V> {
/**
* 可以获取结果,如果获取过程中出现异常会抛出
*
* @return computed result
* @throws Exception if unable to compute a result
*/
V call() throws Exception;
}
一般用法都是:
public class CallableWay implements Callable {
@Override
public Object call() throws Exception {
Thread.sleep(3000);
return "Callable方式处理多线程";
}
@SuppressWarnings("unchecked")
public static void main(String[] args) {
/*
* Callable的创建方式是:
* 先创建一个FutureTask对象,构造参数是Callable对象,它同时实现了Future和Runnable接口
* 然后再由Future创建一个Thread对象
*/
CallableWay callableWay = new CallableWay();
FutureTask task = new FutureTask(callableWay);
Thread t = new Thread(task);
t.start();
try {
//在线程没有执行完所有任务之前,下面这一步get是不会执行的
System.out.println(task.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
}
Callable
的方式克服了前两个在某些情况下可能存在的不足,但是不同于前两个起于JDK1.0,Callable在JDK1.5之前是没有的,所以对于使用JDK1.5之前的老系统存在兼容问题。
问题
最近因为公司项目问题,使用到了kubernetes,项目系统中有三个核心模块是将其打完包之后构建成镜像推入镜像仓库中的,然后采用k8s那一套进行拉取镜像并部署。但是我在测试环境中上线一个上传功能时,频繁出现k8s的pod重启问题,最终定位原因猜测是:Java10之前或者在Java8u191版本之前,对于在容器环境中使用Java程序可能会存在一些bug:
- 在java10之前的版本中,JVM在运行时并不自动检测当前是处于容器中还是处于物理机中,这时候即使我们在利用docker启动容器时额外指定了一些参数,但是JVM任然读取不到,JVM在容器中始终读取的硬件环境都是宿主机的。
- 如果是必须要指定固定的CPU以及内存大小,就必须在运行java程序的启动命令上做限制,如
java -jar -Xmx 500MB
这种,否则JVM无法读取。- 如果没有指定启动参数,默认情况下,JVM读取到的CPU和内存资源就是宿主机的对应情况;而默认情况下JVM使用基于宿主机四分之一的内存作为JVM的最大内存,这样会导致如果容器中Java程序出现较大量的内存吞吐,可能会直接导致容器崩溃进而重启,因为可能限于容器所处宿主机的环境原因,实际容器能用的内存只有500M,JVM采用的最大内存可能是1G(假设宿主机为4G内存),如果宿主机上还有其他程序可能资源会更少。
- Java10之后,默认开启了一个启动参数UseContainerSupport,Java8u191版本之后,UseContainerSupport默认开启,java 9暂未backport这个feature,换句话说,Java10以后,通过docker启动容器时可以同时指定内存以及CPU限制,JVM能够读取到指定的这些参数。