一,深入理解进程、线程与CPU之间的关系
1,进程与线程
1.1,进程与线程的关系
进程用官方的话来说:进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配的基本单位,是操作系统结构的基础。 我们常说的app,就是一个进程,当一个程序被运行,从磁盘加载这个程序的代码到内存时,这就开启了一个进程。如下图,打开资源管理器就可以看到这些进程的情况,内部包含一些内存,磁盘,cpu操作,io操作等
线程指的就是:操作系统能够调度的最小单位 。
进程可以独立存活,而线程必须依赖于进程,也就是说,一个进程中可以包含多个线程。
1.2,在java中进程与线程的关系
在c语言中,一个进程可以没有线程,但是在java语言中,一个进程至少会包含一个进程。java需要依靠虚拟机执行,由于jvm本身就是一个进程,因此可以通过一个简单的方法查看内部线程的相关信息。如下方法查看,在只有一个主线程的main方法中,通过这个 ThreadMXBean 类来获取当前全部线程的信息
/** * @Author: zhenghuisheng * @Date: 2023/7/11 13:50 * 单线程总统计 */ public class ThreadCount { public static void main(String[] args) { // 获取线程管理bean ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean(); // 获取线程和线程堆栈信息 ThreadInfo[] threadInfo = threadMXBean.dumpAllThreads(false, false); for (int i = 0; i < threadInfo.length; i++) { ThreadInfo ti = threadInfo[i]; //打印线程id System.out.println("线程id为:" + ti.getThreadId() + "线程名称为:" + ti.getThreadName()); } } }
其打印的日志如下,从而可以发现本以为只执行了一个main线程,打印日志的时候多了5个线程,在jvm内部会默认启动一些引用线程,垃圾回收,监视器,监听器等线程。所以说在java中,一个进程至少会有一个线程
线程id为:6线程名称为:Monitor Ctrl-Break //监控中断信号的 线程id为:5线程名称为:Attach Listener //监听内存dump,类信息统计,获取系统属性等 线程id为:4线程名称为:Signal Dispatcher //分发处理发送给JVM信号的线程 线程id为:3线程名称为:Finalizer //调用finalize方法的线程 线程id为:2线程名称为:Reference Handler //清除Reference线程 线程id为:1线程名称为:main
2,进程间的通信方式
同一台计算机的通信称为IPC,不同计算机的通信被称为RPC,需要通过网络并遵守共同的协议。进程与进程之间的通信,主要有几下几种方式:管道、信号、消息队列、共享内存、信号量、套接字
2.1,管道
管道又分为匿名管道和命名管道。匿名管道主要是用于父子进程之间的通信,如一个线程fork一个独立的子线程,那么这个线程和这个子线程就可以通过匿名管道的方式进行通信
命名管管道就是在匿名的管道的基础上,允许无亲缘关系的进程之间的通信。
2.2,信号
如上面的main主线程中,可以发现执行完会有一个 Signal Dispatcher 信号分发的线程,主要用于发送信号和接收信号,类似于一种软件层面的中断,与处理器的中断效果一致,可以通知另外一个进程即将有某个事件要发生
2.3,消息队列
这个消息队列和rabbitMq一样,就是在内存中的一个链表。通过这种方式克服了前面两种通信方式种信号量有限的缺点。就是一边负责将这个信号放到消息队列中,一边负责去接收这个信号量
2.4,共享内存(重点)
共享内存这种方式可以说是最有用的进程间的通信方式,就是开辟一个共享空间,每个进程都可以访问和修改这块内存空间,不同的进程可以及时的看到对方进程中对共享数据的更新。这种方式需要依赖互斥锁或者信号量的操作,从而解决可能出现的并发问题。
如下有着三个进程abc,都可以同时的操作上面公共的物理内存空间,从而实现共享内存的方式
2.5,信号量
主要是通过这个clh同步等待队列实现,主要是作为进程之间以及同一个进程不同线程之间的同步和互斥手段
2.6,套接字
就是一个socket的套接字,一般用于网络中不同机器之间的进程间的通信,其应用相对广泛。并且在同一台机器中,可以使用 Unix domain socket ,这种方式不需要经过网络协议栈,不需要打包拆包,计算检验和,维护序号和应答等,比纯粹基于网络间的进程通信效率更高。如典型的mysql,不管是从控制台还是从其他机器连接,由于其内部使用的是套接字的连接方式,其实现方面和这个网络的实现方式是一样的,因此其内部和本机可以共用一套代码。套接字的类型主要分有:流套接字、数据报套接字、原始套接字 。
3,CPU核心数和线程数的关系
每个进程都要使用共享的cpu,目前主流的CPU都是多核的,线程是CPU调度的最小单位,也就是说,一个核心CPU只能运行一个线程,但Intel引入这个超线程技术之后,产生了逻辑处理器的概念,使得一个cpu中,可以执行两个线程,从而让cpu数和线程数达到 1:2 的关系 。如下图中,可以查看得到内核是2,逻辑处理器是4
也可以直接通过java代码来查看当前机器的cpu个数,显示的结果就是对应的逻辑处理器的个数
//获取的结果是4,而不是2 Runtime.getRuntime().availableProcessors();
在使用线程池时,可以根据判断是io密集型还是cpu密集型,从而来设置这个最大核心数的数量。一般io密集型的最大核心线程数为 2N ,cpu密集型的最大核心线程数为 N或者 N+ 1,这里的N指的就是逻辑处理器的个数。
//获取当前机器的逻辑处理器的个数 int n = Runtime.getRuntime().availableProcessors(); //创建io密集型线程池 new ThreadPoolExecutor( n*2 - 2,n*2,5L,TimeUnit.SECONDS,new LinkedBlockingDeque<>() ); //创建cpu密集型线程池 new ThreadPoolExecutor( n - 1,n,5L,TimeUnit.SECONDS,new LinkedBlockingDeque<>() );
4,上下文切换
操作系统要在多个进程之间进行任务调度,而每个线程在使用CPU时总是要使用CPU中的资源,因此操作系统为了保证线程在调度前后的正常执行,就需要一个上下文切换的操作,指的是从一个进程切或线程切换到另一个进程或者线程的切换
cpu内部还包含一些缓存,寄存器,程序计数器等,当cpu从一个线程切换到某另一个线程时,这些属性存放的数据对应的就是该线程内部的数据。而之前线程的数据,如果该线程任务还没有执行完的话,则会通过CPU寄存器或者程序计数器 保存起来,在下一次切换到该线程时,则只需要从结束的那一刻继续往下执行即可。如在jvm的程序计数器中,会保存对应线程的字节码指令,在cpu轮询切换到该线程时,则直接从对应的行数开始执行
上下文切换可以更为详细的描述为内核态对cpu上的进程或者线程的活动:暂停当前进程或者线程的执行,并将此时的CPU状态存储,然后获取一个新的进程或者线程的上下文,在CPU寄存器中恢复
引发cpu上下文切换的原因,主要有:进程、线程切换,线程调度等
5,java中线程
5.1,创建线程的方式
在java的官方jdk中提到,创建线程主要有两种方式: There are two ways to create a new thread of execution. One is to declare a class to be a subclass of Thread . The other way to create a thread is to declare a class that implements the Runnable interface.
就是说:创建线程的方式有两种,一种是继承Thread,一种是实现Runnable接口 。
public class ThreadTest extends Thread { @Override public void run() { } }
通过实现runnable的方式创建线程,随后将这个实例作为new Thread的参数
public class ThreadCreate implements Runnable { @Override public void run() { } } public class Main(){ public static void mian(String args[]){ ThreadCreate threadCreate = new ThreadCreate(); Thread thread = new Thread(threadCreate); } }
上面两种方式都需要通过Thread类来创建线程,但是这些Thread方法都是void类型,也就是说不会将值返回,因此出现了callable这种创建线程的方式
通过实现callable来获取返回值的方式如下
public class ThreadCreate1 implements Callable { @Override public Object call() throws Exception { return null; } }
但是在通过Thread 创建线程的方法中,没有支持callable作为参数的方法,因此就需要将callable转化成runnable,在此就需要借助到一个重要的类 FutureTask ,其类图如下
而futureTask中的参数正好是这个callable,因此可以通过包装的形式,将这个callable转换成Runnable
public FutureTask(Callable<V> callable) { if (callable == null) throw new NullPointerException(); this.callable = callable; this.state = NEW; // ensure visibility of callable }
将callable转换成Runnable的方式如下,将这个FutureTask向上转型成Runnable,随后将这个Runnable作为Thread的参数,这样子就可以创建出线程了,同时有返回值也可以通过这个 futureTask.get() 获取到任务的返回值
public class ThreadCreate1 implements Callable { @Override public void run() { } } public class Main(){ public static void mian(String args[]){ ThreadCreate1 threadCreate1 = new ThreadCreate1(); //向上转型 //Runnable futureTask = new FutureTask(threadCreate1); FutureTask futureTask = new FutureTask(threadCreate1); Thread thread = new Thread(futureTask); //获取任务返回的结果 futureTask.get(); } }
5.2,线程的启动和停止
5.2.1,线程的启动
线程的启动比较简单,就是在创建线程之后,调用start方法就可以启动,start()方法让一个线程进入就绪队列等待分配 cpu,分到 cpu 后才调用实现 的 run()方法,start()方法不能重复调用,如果重复调用会抛出异常
ThreadCreate threadCreate = new ThreadCreate(); Thread thread = new Thread(threadCreate); thread.start();
由于一个Thread在java中只是一个对象,在调用start之后,会调用一个本地方法
private native void start0();
也就是说一个对象会对应本地的一个线程,所以一个线程不能同时start两次,如果两次则会抛出异常
thread.start(); //抛出异常 thread.start();
而run方法在java中,其实也只一个普通的方法,也可以直接调用,但是通过打印出来的线程可以发现执行run方法的线程不是当前线程,而是main方法主线程
public class ThreadCreate implements Runnable { @Override public void run() { System.out.println("当前线程名称为" + Thread.currentThread().getName()); } } public class Main(){ public static void mian(String args[]){ ThreadCreate threadCreate = new ThreadCreate(); //直接执行run方法 thread.run(); } }
5.2.2,线程的终止
线程的终止分为多种情况:一种是自然终止,如run方法中把整个任务给执行了,或者说是代码出现异常的时候也会终止;一种是中断 出现的停止,如调用这个Thread.interrupt() 方法。
自然终止也可以是手动的调用一些如 stop()、suspend()、resume() 等方法,但是这些方法上面都有一个 Deprecated 过期的注解,表示已经被废弃。主要是因为这些方法虽然可以让操作系统进行一个停止或者挂起的操作,但是这个过程中这些被挂起或者停止的线程并不会去释放资源。如stop方法是一个强力的结束线程的方法,但是就是因为力度太大,导致不会去释放cpu等其他资源,或者造成文件损坏;suspend是一个线程挂起的方法,被挂起阶段也不会释放资源,如一些锁,文件等,就会出现死锁等问题。
因此为了更优雅的停止线程并且可以释放资源,则引入了这个 interrupted 方法。如thread1线程通知thread2线程马上要中断了,那么thread2接收到这个信号后会先判断一个标志位是否为true,如果为true则先进行一个释放资源的操作,再进行中断
public static boolean interrupted() { //检查标志位是否为true return currentThread().isInterrupted(true); }
interrupt 是用来中断操作,interrupted 方法用来判断是否需要中断,也就是说也可以不中断,即不理会这个中断请求,理会了就一定会先释放资源,再进行一个挂起或者停止的工作。
//设置中断的标志位为true thread.interrupt();
总的来说就是在设置标志位之后,需要通过代码检查这个标志位,标志位默认是false,在调用interrupt方法之后,就会将这个标志位改为true,如果在线程中不去检查这个标志位,那么线程和以前一样执行,如果做一个线程标志位的检查,通过查看这个isInterrupted方法,如果为true,当前线程会先释放资源,再挂起或者之间停止。
public class ThreadCreate implements Runnable { @Override public void run() { Thread thread = Thread.currentThread(); //用于判断标志位 while (!thread.isInterrupted()){ System.out.println("线程没被中断"); } } } public class ThreadCount { public static void main(String[] args) { ThreadCreate threadCreate = new ThreadCreate(); Thread thread = new Thread(threadCreate); //线程中断,将标志位改为true thread.interrupt(); } }
也就是说,thread.interrupt() 和 thread.isInterrupted() 要结合使用。并且这个标志位最好是通过这个thread.isInterrupted()来作为判断条件,最好不要自定义标志位,以免出现因为阻塞而导致这个标志位的改变没有被快速的检测到。如果是出现死锁的情况,那么该线程不能使用阻塞停止