前言:
大家好,我目前在学习java。我准备利用这个暑假,来复习之前学过的内容,并整理好之前写过的博客进行发布。如果博客中有错误或者没有读懂的地方。热烈欢迎大家在评论区进行讨论!!!
喜欢我文章的兄弟姐妹们可以点赞,收藏和评论。如果感觉有所收获可以关注我呦。我会持续更新滴,望支持!!!!!!一起加油呀!!!!
语言只是工具,决定你好不好找工作的是你的能力!!!!!
学历本科及以上就够用了!!!!!!!!!!
本篇博客会简单介绍线程、线程的特点、优点、线程的不安全问题、进程与线程的区别、Java中如何进程多线程编程、Thread类。
c++中会讲很多多进程编程,而在Java这样的生态中,并不是很鼓励多进程编程,更鼓励多线程编程。
引入多个进程,目的是为了实现并发编程=>多核cpu的时代。
多进程实现并发编程效果很好,但是多进程编程模型也有明显的缺点:
多进程编程模型的缺点
进程太重量,效率不高。
创建一个进程,销毁一个进程,调度一个进程消耗时间都比较多。
时间消耗在申请资源上。进程是资源分配的基本单位。分配内存操作是一个复杂的操作。
(操作系统内部有一定的数据结构,把空闲的内存分块管理好,当我们进行申请内存的时候,系统就会从这样的数据结构中找到一个大小适合的空闲内存。返回给对应的进程。这里虽然通过此处的数据结构,可以一定程度提高效率,整体来说,管理的空间比较多,相比之下,还是一个耗时操作。)
如果频繁创建/销毁进程时,这个耗时就不能忽视了
为了解决上述问题,就引入了“线程”(Thread)
一、线程也叫做“轻量级进程”
线程不能独立存在,而是要依附于进程,(进程包含线程,可以包含一个或多个)
一个进程最开始至少要有一个线程,这个线程负责完成执行代码的工作。
也可以根据需要,创建出更多的线程,从而使当前实现“并发编程”的效果
每个线程都可以独立执行一些代码。
之前提到的进程调度
是基于“一个进程里只有一个线程”的情况。
实际上,一个进程中,是可以有多个线程的~~每个线程都是可以独立的进行调度的~~
因此以后看到进程的调度,我们就知道,并不是把整个进程进行调度。而是去调度进程里面的每一个线程,每一个线程执行一些逻辑,每一个线程就可以分别在这上面进行调度。每一个线程也有
状态
优先级
上下文
记账信息....
一个进程,可能使用一个PCB表示,也可能使用多个PCB表示,每一个PCB对应到一个线程上,因此每一个线程都有自己的状态、优先级、上下文、记账信息....每一个线程都有这些信息进行辅助调度~~
除此之外,前面谈到的pid,是相同的。内存指针,文件描述符表也是相同的。共用同一份的
线程和线程之间共用同一份pid、内存指针、文件描述符表
二、线程的特点
1.每一个线程都可以独立的去cpu上调度执行
2.同一个进程的多个线程之间共用同一份内存空间和文件资源...
三、线程的优点
创建效率更高
(创建线程的时候,不需要重新申请资源了,直接复用之前已经分配给进程的资源,省去了资源分配的开销,于是创建效率就更高了)
总结:
进程中包含线程,
一个进程由多个PCB共同表示,
每个PCB就用来表示一个线程,
每个线程都有自己的状态、优先级、上下文、记账信息....
每个线程都可以独立去CPU上调度执行,
这些PCB共用了同样的pid、内存指针、文件描述符表
创建线程(PCB)的时候,不需要重新申请资源了,直接复用之前已经分配给进程的资源,省去了资源分配的开销,于是创建效率就更高了
进程是资源分配的基本单位
线程是调度执行的基本单位
一个系统中,可以有很多进程,每个进程都有自己的资源
一个进程中,可以有很多线程,每个线程都能独立调度,共享内存/硬盘资源
四、多线程方式
刚开始创建第一个线程的时候,相当于和进程一起创建,还是需要有一定的开销去申请资源的(这个账是记在进程上面的)后面再创建线程,开销就省下了。
创建多线程方式可以提高效率,但是线程到达一定的数量,效率就无法进一步提升了,反而会因为需要调度的线程太多,使调度的开销更大,反而降低效率。线程多了,也容易产生一定的冲突。
五、线程不安全问题
如果一个线程抛出异常,如果没有妥善处理(要catch住),就容易使整个进程崩溃。此时其他线程也会随之消亡。
六、进程与线程的区别(经典面试题)
1.进程包含线程,一个进程里面可以有一个线程,也可以有多个线程
2.进程和线程,都是用来实现 并发编程 场景的,但是线程比进程更加轻量,更高效
3.同一个进程的线程之间,共用同一份的资源(内存+硬盘),省去了申请资源的开销
4.进程和进程之间,具有独立性,一个进程挂了,不会影响其他进程
同一个进程的线程和线程之间,可能互相影响,(线程安全问题+线程出现异常)
5.进程是资源分配的基本单位,线程是调度执行的基本单位。
七、Java如何进行多线程编程
7.1 基本的多线程编程
创建线程的方法
①继承Thread类,重写run方法
线程是操作系统的概念,操作系统提高了一些API,可以操作线程Java针对上述系统API进行了封装(实现跨平台)程序员只需要掌握这一套API就可以了。
Thread类,创建Thread类对象,进一步的就可以操作,系统内部的线程了。使用这个类,创建出一个线程出来。继承“Thread”是Java标准库内置的类,我们直接就能使用。
此处Thread不需要import也能使用,是因为Thread这个类在java.lang包下。
1.创建一个类继承Thread。再重写run方法。这个run方法就是线程的入口方法。入口方法就是代表线程一旦执行起来后,具体要执行哪些逻辑。类似于main方法。
:每个线程都是独立的执行流,每个线程都可以执行一系列的逻辑(代码)一个线程跑起来,就是从它的入口方法开始执行。
类比运行Java程序:就是跑起来一个java进程,这个进程里面至少会有一个线程,主线程的入口方法就是main方法。
2.2.创建一个主线程,也就是在main方法中创建一个Thread的实例,创建好了之后再去调用start方法。
start和run方法的区别的功能描述
//start和run都是Thread的成员
//run只是描述了线程的入口(线程要做什么任务)
//start则是真正调用了系统API,在系统中创建出线程,让线程再调用run
这里的创建线程,实在系统内核里面创建线程。涉及到创建PCB并且加入到内核链表里面,这样创建好的线程就会进一步执行我们的run方法。这样就可以将新的线程创建出来。
此时若在run中有System.out.println(“hello thread”) ;
那么运行程序,就会打印出hello thread
给打印代码加上while(true),死循环,在线程和主线程中一个打印hello thread,另一个打印hello main。运行代码,我们可以发现两边的日志都在交替打印
1.每个线程都是独立执行的逻辑,独立的执行流。
2.从t.start();代码之后,就会兵分两路,并发执行。达到了并发编程的效果,充分的使用了多核cpu资源。
把t.start()改成t.run()。并不会创建新的线程,只有一个主线程,这个主线程依次执行循环,执行完一个循环再执行另一个。
main这个线程是jvm自动创建的,和其他线程相比,没啥特殊的。
一个Java进程中,至少会有一个main线程。
7.2 查看该进程里的多线程情况
多线程程序运行的时候,可以使用IDEA或者jconsole来观察到该进程里的多线程的情况
IDEA对新手不太友好,以调试模式启动程序,会有一个专门的窗口,查看方法的调用栈,在这里可以看到所有线程的信息。
jconsole来观察到该进程里的多线程的情况(jdk的bin目录中)
1.启动之前,确保idea中的程序已经跑起来了
2.若啥都不显示,可能需要使用管理员方式运行
它会列出当前机器上所运行的所有java进程
使用方法:
1.双击jconsole.exe,出现如下窗口,点击本地进程中你正用IDEA跑起来的进程。点击连接
2.出现如下窗口,我们要查看线程情况,点击线程
在jconsole,可以看到一个java进程,即使是最简单的,里面也包含了很多线程。
Thread = new MyThread();是自己动手创建的,其他的线程都是JVM自动创建的。一个java进程,启动之后JVM会在后面,默默帮我们做很多事情,比如垃圾回收、资源统计、远程方法调用...
我们只关注两个
1.main是主线程
2.Thread-0是我们创建的线程 ,点进去我们可以看到详细信息。
最主要,我们看堆栈跟踪(也就是线程的“调用栈”):描述了方法的调用关系。
功能:
未来写一些多线程程序的时候,就可以借助这个功能看到该程序实时的运行情况,比如你写的程序“卡死了”。
让while循环慢点(sleep)
在循环体里加上sleep,休眠
Thread.sleep();
输入参数的单位为毫秒。例如:
Thread.sleep(millis:1000);
此语句需要抛出异常,要么往上throws或者进行try,catch。
在当前我们写的线程中(重写run方法),我们必须进行try,catch,因为我们现在是方法重写,如果父类的run方法没有throws,那么子类这个方法也就没法去throws。
而在主线程(main方法中),我们可以进行throws。
sleep是Thread的类(静态)方法。
我们发现两线程都是休眠1000ms,当时间到了之后,这俩线程谁先执行,谁后执行不一定。这个过程可以视为“随机的”。
“对多线程调度顺序的“随机性””
因为操作系统对于多个线程的调度顺序,是不确定的,“随机的”(此处的随机不均等,可能优先级不一样,就算一样是不是均等也很难说,取决于操作系统对于线程调度的模块,调度器的实现),
类方法,类属性 VS(普通) 实例方法,实例属性
类方法,类属性,直接用类名就可以调用
实例方法,实例属性,需要用类实例化对象,用对象名进行调用。
ps:static历史问题
c语言最初引入了static,以前的操作系统,运行的进程中,专门有一个内存区域,叫做“静态内存区”随着时间发展,静态内存区没有了,后来c就是用static表示其他含义了。
如果static修饰一个全局变量,或者修饰一个方法,表示它的作用域,就在当前.c文件里
如果修饰一个局部变量,那么就表示这个变量的生命周期是跟随整个程序的。
c++中把static又赋予了新的含义,c++引入了类和对象的概念,static就是类的成员,达到“类方法”“类属性”定义效果了(如果新加关键字来表示“类方法”“类属性”不合适,这会导致现有的代码可能产生冲突。如与之前变量名一样,c++要考虑和c兼容,有很多程序使用c,若引入关键字,可能导致现有的代码无法编译。)
java是从c++这边参考过来的,因此java这边也就用static表示类方法,类属性
Python没有这样的历史包袱,因此python直接使用@classmethod这样的方式来表示类方法。
7.3 创建线程的其他写法
②实现Runnable接口,重写run方法
实现Runnable接口,重写run方法
这里的内容和之前继承Thread类是一样的。也是描述了线程的入口。
不同的是,这里在main方法中,需要创建Runnable接口的实例化,描述一个任务。
再创建Thread的类的实例化,将Runnable的实例化交给Thread来执行。我们把这个任务放到线程里面去执行。通过t.start();通过这个操作,调用系统api来完成创建线程的工作~
Runnable
Runnable本身并没有和线程进行联系,单纯的表示一个可运行的任务,这个任务是交给线程负责执行,还是交给其他实体来执行....Runnable本身并不关心。
ps:为什么总是向上转型(java)
Java这个圈子就爱这么写,
如果c++,这里的代码绝对不会写成向上转型。
c++是一个生态,这里的这群人不喜欢向上转型,他们觉得向上转型之后,触发多态,会有额外的运行时开销,不符合c++把性能追求到极致这样的初心。这边能不向上转型就不转型。
如果java,一定写成向上转型的方法。
java也是一个生态。这群人更鼓励使用向上转型,java程序员觉得性能问题不是问题,开发效率大于运行效率。使用向上转型,抽象层次高,代码的使用/理解成本更低。(能向上转型就向上转型。)
多态的本质
封装本质上是让调用者不再了解类实现的细节,从而降低了学习和使用成本。
多态则是在封装基础上更进一步,更是让你不知道当前是啥类。更不用说类的实现细节,你只需要关心它的父类。
就如这里,
只需要看到Runnable runnable(runnable是Thread类型)。而不需要关心后面new 了一个怎么样的runnable
只需要看到Thread t(t是Thread类型),而不需要关心后面new 了 一个怎样的Thread。属于将封装程度更加提高了。学习成本更降低了。
未来在公司中,接触到的各种代码,向上转型,也会非常普遍。
代码比较复杂,容易体会到封装/多态 的意义,
两次创建线程的差别
解耦合。
使用Runnable的写法,和直接继承Thread之间的区别主要是 解耦合。
相互影响越大,我们认为是耦合越高
创建线程,需要两个关键操作:
1.明确线程要执行的任务,
2.调用系统api,创建出线程。
任务本身,不一定和线程 概念 强相关,
这个任务只是单纯的执行一段代码,这个任务是使用单线程,还是多线程执行,还是通过其他方法(信号处理函数 / 协程 / 线程池)都没啥区别。
因此我们可以把任务本身给提取出来~我们将任务和线程之间进行解耦合,解耦合之后,我们随时就可以把任务改成其他方式来去执行。
匿名内部类
在数据结构课程的优先级队列中,我们学到过,能够按照优先级高低,来决定谁先出队列。
如果存的对象,那么谁优先级算高,谁算低呢,此时我们使用Compareble或者Comparator来定义,比较规则.
使用这个的时候,我们就可以使用匿名内部类来进行定义了
③.继承Thread重写run,但是使用匿名内部类.
public class Demo3 {
public static void main(String[] args) {
Thread t = new Thread(){
};
}
}
先创建出新的类,这个类的名字是啥,不知道
只知道这个类,是Thread的子类
同时又把这个子类的实例给创建出来了
(不知道这个类名,不影响,因为这个类本身就是只使用一次)
package thread;
public class Demo3 {
public static void main(String[] args) {
Thread t = new Thread(){
@Override
public void run() {
while (true){
System.out.println("hello thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
};
t.start();
while (true){
System.out.println("hello main");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
这样看起来更直观。通过匿名内部类创建线程,这个方法本质上和方法①是一样的。只是换了一种写法。匿名内部类这种写法,在当前java中是比较常见的
④.实现Runnable,重写run,也是使用匿名内部类。
package thread;
public class Demo41 {
public static void main(String[] args) {
Runnable runnable = new Runnable() {
@Override
public void run() {
while (true){
System.out.println("hello Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
};
Thread t = new Thread(runnable);
t.start();
while (true){
System.out.println("hello main");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
可以看到这样创建所执行的效果和之前也都是一样的,使用这种方式也是匿名内部类的写法。甚至我们可以连Runnable的变量名都可以不要。如下:
package thread;
public class Demo42 {
public static void main(String[] args) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
while (true){
System.out.println("hello Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
});
t.start();
while (true){
System.out.println("hello main");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
总之匿名内部类在java中很常见,要重点掌握哦!
⑤.基于lambda表达式(最推荐写法)
package thread;
public class Demo5 {
public static void main(String[] args) {
Thread t = new Thread(()->{
while (true){
System.out.println("hello thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t.start();
while (true){
System.out.println("hello main");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
lambda(枚举,反射),是一种更简化的语法表示方式,我们称之为“语法糖”
例如for each遍历数组。
for(int i = 0; i<arr.length; i++) 这是一种遍历方式
for(int x : arr) 这也是一种遍历方式,这种更简洁,可以称之为语法糖
相当于匿名内部类的替换写法
Thread t = new Thread(()->{
});
lambda表达式:
这样的写法称之为lambda表达式,其本质上是一个匿名方法。匿名函数(用一次就不用了)。主要用来实现这种“回调函数”的效果。
Java中不允许函数独立存在,必须依托于一个类,(其他语言叫函数function,java这里叫做方法method)
因此这里个代码里面,看起来像是一个单独的函数,本质上是一个函数式接口,还是一个类或者是一个对象。lambda 本质是一个函数式接口(本质上还是没脱离类)。
函数指针:
是指向内存空间的,函数怎么跑到内存中,原因是操作系统,加载 一个可执行程序,创建进程的过程。当写的代码都是一个一个的文件,我们将他们预编译,得到一个exe.文件。还是一个文件,这个时候,函数在文件里,但是当我们双击exe文件,操作系统就会 加载这个exe文件,将exe文件中的指令和数据加载到内存中,构建成一个进程,这个时候,我们写的函数,对应的二进制指令就进入到内存中,这个时候拿指针指向它。
作用:
1.使用函数指针实现转移表,降低代码的圈复杂度(减少 if else 分支数目)
2.使用函数指针作为回调函数(qsort)
回调函数:
回调函数,不是你主动调用,也不是现在就立即调用,而是把调用的机会交给别人(通常是操作系统,库,框架,别人写的代码)来进行使用,别人会在合适的时机来调用这个函数。
java中可以使用,lambda表达式和匿名内部类来描述这个回调函数。
八、Thread类的其他使用方法
Thread t1 = new Thread();
Thread t2 = new Thread(new MyRunnable());
Thread t3 = new Thread("这是我的名字");
Thread t4 = new Thread(new MyRunnable(),"这是我的名字");
前两个我们已经见过了,第三个
8.1 Thread(String name)方法
name,在创建线程的时候,我们可以去指定一个name,name不影响线程执行,只是给线程起个名字,后续在调试的时候,比较方便区分。
使用示例如下:
package thread;
public class Demo6 {
public static void main(String[] args) {
Thread t = new Thread(() ->{
while (true){
System.out.println("hello thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
},"这是新线程");
t.start();
}
}
运行后,我们在JDK目录中,运行 jconsole.exe文件。
我们发现main线程咋不见了?
因为main执行完了,所以就没有了!!
线程的入口方法执行结束,则这个线程就自动销毁了
对于主线程来说,入口就是main方法
t.start();这个方法,一瞬间就执行完了。当start方法执行完毕之后,紧接着main方法执行结束,那么这个主线程自然没有了,销毁了。线程不是创建了就一直存在,而是执行完了自然就销毁了。
8.2 Tread的几个常见属性
属性 获取方法
ID getId() ID是线程唯一身份标识,不同线程不会重复。(这个id是 Java给你这个线程分配的,不是系统api提供的线程id,更不 是pcb中的id)
名称 getName()
状态 getState() 就绪,阻塞...等等许多后面我们再去讨论。
优先级 getPriority() 虽然提供了api可以设置/获取优先级,但是没什么大用,
从应用程序角度出发,很难察觉出来,优先级带来的差 异,优先级影响到的是系统在微观上进行的调度。
后面我们再去详细讨论。
是否后台线程 isDaemon() 重点介绍:这个也叫做守护线程(后台线程) ,相对的有 前台线程,如果前台线程没有执行结束,此时整个进程是 一定不会结束的。而后台进程没有执行结束,并不影响整 个进程的结束。默认情况下,一个线程是前台线程,除非 把他手动定义成setDaemon(true) 。
是否存活 isAlive() Thread对象的生命周期,要比系统内核中的线程更长一些
Thread对象还在,内核中的线程已经销毁了这样的情况
我们可以通过isAlive判定内核线程是不是已经没了
回调方法执行完毕,线程就没了
是否被中断 isInterrupted()
是否后台线程 isDaemon()
示例:
package thread;
public class Demo6 {
public static void main(String[] args) {
Thread t = new Thread(() ->{
while (true){
System.out.println("hello thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
},"这是新线程");
//设置t为后台线程
t.setDaemon(true);
t.start();
}
}
运行程序,我们发现什么都没有打印,
改成后台线程之后,主线程飞快执行完了,于是进程结束,t线程还没来得及执行,就完了。
是否存活 isAlive()
package thread;
public class Demo7 {
public static void main(String[] args) {
Thread t = new Thread(() ->{
System.out.println("线程开始");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("线程结束");
});
t.start();
System.out.println(t.isAlive());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(t.isAlive());
}
}
我们让主线程sleep3000 ,所以当sleep3000完成之后,那么这个线程执行完毕。我们打印 System.out.println(t.isAlive());
如果线程正在运行,我们调用isAlive() 那么就打印true
如果线程结束,我们调用isAlive() 那么就打印false
true和线程开始,这两条日志,谁先打印,谁后打印不一定,因为线程是并发执行的,并发调度顺序不确定,取决于系统的调度器,(自己尝试,大概率先打印true,因为调用start之后,新的线程被创建也是有一定开销的,创建线程过程中,主线程就执行println)
但是无法排除极端情况,比如主线程正好卡了下,使新线程的日志先打印......
如果在t.start前调用isLive这个时候线程没被创建出来,自然也会打印false