多线程编程-Java

目录

一、计算机的组成

二、操作系统

0x00 什么是操作系统

0x01 进程/任务

0x02 管理进程

0x03 虚拟内存空间

三、线程

0x00 多进程

0x01 多线程

0x02 多线程的问题

0x03 进程与线程的区别

四、多线程编程

0x00 如何创建线程

0x01 线程如何执行

0x02 线程的创建方式

五、Thread类及常用的方法

0x00 Thread类的构造方法

0x01 Thread的几个常用的属性

0x02 中断一个线程

0x03 线程等待

0x04 线程状态

0x05 线程安全


一、计算机的组成

1、CPU:中央处理器,一台电脑最核心的部分,通过指令来完成各种算术运算和逻辑判断,是人类科技巅峰之作。

补充

cpu虽然技术门槛非常高,但是很便宜,这是因为cpu每年都在更新换代,并且每一代cpu的提升幅度都比较大,因此并不保值。intel的创始人之一提出了一个摩尔定律:集成电路上可容纳的晶体管数量每隔大约18至24个月就会翻倍,从而导致芯片性提升接近一倍,同时成本也下降一半。

在cpu中还有寄存器,用于暂时存储指令,操作数和中间结果,寄存器的存储空间更小访问速度更快(相比于内存),大概相差3-4个数量级,因此为了协调工作,cpu又引入了“缓存”来协调 寄存器 和 内存 之间的速度。

2、存储器:分为内存和外存。

内存:速度快,空间小,成本高,不能持久保存

外存:速度慢,空间大,成本小,可持久保存

这里说的快和慢是内存和外存相对而言的~

3、输入设备:键盘、鼠标、摄像头......

4、输出设备:显示器、打印机、扬声器......

二、操作系统

0x00 什么是操作系统

市面上存在着许多的操作系统,如:Windows11,linux,mac os, android,ios........

这些操作系统,需要对下管理硬件设备,对上提供稳定的运行环境

由于操作系统毕竟只是一个软件,不可能识别所有硬件设备,不过硬件设备就这么几个大类,因此操作系统只要知道每个大类中有什么功能,然后硬件产商在开发硬件的同时开发一个驱动程序(软件),让操作系统通过这个驱动程序完成对硬件设备的控制。可以这么理解:

0x01 进程/任务

一个运行起来的程序就叫做进程。每个进程要想运行,就需要消耗一定的系统资源。因此进程是系统资源分配的基本单位。

0x02 管理进程

一个操作系统上可能跑了很多进程,那如何对这些进程管理呢?两方面:描述、组织

1、使用类来描述一个进程,把管理的每个对象都用属性表示出来

2、使用数据结构组织这些进程对象,类似于双向链表,方便创建、销毁

在系统中专门有一个结构体(操作系统内核是c/c++写的,java中结构体相当于类)来描述进程的属性,这个结构体叫做“进程控制块”PCB


PCB时一个非常庞大的结构体,包含很多的属性,在这就讨论一些比较重要的属性。

1、pid:

进程的身份标识,每一个进程都会有一个pid,同一时刻,不同进程之间的pid是不同的。

2、内存指针

描述了进程持有的“内存资源“,每个进程运行的时候,都会分配一定的内存空间。这个进程的内存空间,具体在哪,分配的内存空间有哪些部分,每个部分是干啥的,有这么一组指针来进行区分。

3、文件描述符

描述了进程持有的“硬盘资源”,类似于顺序表这样的数据结构,有很多元素。如果一个进程涉及到了文件操作,保存数据啥的,就需要与硬盘挂钩,于是需要通过文件读写的方式来操作,将硬盘上的数据修改/读取。


我们都知道一个程序的运行离不开cpu的运转,也就是说每个进程都需要消耗cpu资源,那怎么体现cpu资源呢?答案:进程的调度

早期的操作系统,是一个“单任务操作系统”,同一时刻只有一个进程能运行,运行下一个进程就会退出当前进程,此时就不需要考虑调度问题,但随着发展,后面都支持多任务了,那如何运行这多个进程呢?——分时复用

假设cpu是单核,如果使用分时复用的方法,就是将1s分成许多份,每个进程获取其中的一份或者多份,cpu在这一份的时间内执行这个进程。这样的执行方式叫作并发执行,只要我们时间轮转的够快(分成的份数够多),此时看起来就像是同时执行一样(宏观上是同时执行的,微观上是分时复用)。

现代的cpu都是多个核心了,此时我们可以在同一时刻使用两个核心执行进程,这种执行方式叫做并行执行,也就是说在微观上也是同时执行的

不过在应用层,我们感知不到是并发还是并行,其内部都是通过系统调度来选择的。平时普通的程序员也不会去具体区分是并发还是并行,因此常常使用“并发”来代指并行和并发

操作系统如何进行进程调度呢?通过如下属性~


4、进程的状态

表示进程的状态,如就绪、运行、阻塞等,以指导操作系统的调度决策。

就绪状态:进程准备好运行,只等待操作系统分配处理器时间。

阻塞状态:进程某种执行条件不具备,导致这个进程暂时无法参与cpu的调度执行。例如:进程等待用户输入......

运行状态:进程当前正在cpu上执行其指令,正在占用cpu资源,以完成其所需的计算和任务。

5、进程的优先级

操作系统在调度多个进程的时候并非一视同仁,有的进程会给更高的优先级,优先执行。例如:接受QQ消息,晚个几秒钟也没啥关系。

6、进程的上下文

进程从cpu离开之前,需要保存当前数据,把当前cpu中寄存器的状态都记录到内存中,等到下次进程回到cpu上执行的时候,再把保存的值恢复回去,进程就沿着上次执行到的位置继续执行,类似于游戏打了一半,然后去吃饭了,等下次继续的时候,读取数据恢复到上次的关卡。

补充:

CPU中有些寄存器没有特殊含义,只是用来保存运算的中间结果,还有些寄存器是有特定含义的。

1、程序计数器

存储的是一个内存地址,用来保存当前执行到哪个指令(可以理解为指针)。一个进程在运行的时候,操作系统会将里面的指令和数据加载到内存中并形成地址,然后CPU就会从内存中通过查询地址的方式找到指令并执行指令。

2、维护栈相关的寄存器

程序在调用函数的时候会创建栈帧,而栈是一块空间,通过维护头和尾就可以知道个空间的位置,大小。如:ebp寄存器是存储的地址是栈底,esp寄存器存储的是栈顶,通过修改esp的值就可以实现“入栈”,”出栈“操作。

3、其他的通用寄存器

一般是用来保存计算的中间结果。如:当前有一个表达式, 10+20+30+40,假设现在算完了前面两项的和,但还没来得及算后面的,此时进程调度走了,就需要将保存计算的前面两项的和的寄存器的值备份到上下文中。

7、进程的记账信息

通过优先级的机制,对不同的进程分配了不同权重的资源,不过有可能会出现极端的情况,将所有资源都分配给了某个进程,其他进程没有分配到,通过记账信息记录当前进程在cpu的执行情况(执行时间),然后操作系统可以参考记账信息来进行下一次的调度进程

0x03 虚拟内存空间

早期的操作系统,程序运行时分配的内存就是“物理内存”,这样就会导致,我的A程序通过某种方式访问到B程序的内存空间,然后进行修改,这就有可能会使B程序崩溃,为了防止这种情况,就引入了“虚拟内存空间”

操作系统通过使用“虚拟内存地址”,不直接分配物理内存,而是分配虚拟的内存空间,类似哈希的思想,通过某个key来寻找value。

 此时进程A想要操作某个内存中的数据,先通过虚拟内存地址告诉操作系统,然后操作系统将虚拟内存翻译成物理内存地址,进行修改,在翻译的过程中,操作系统就可以进行校验操作,如果是非法访问就可以处理,不会危害到别的进程了,保持了进程的独立性,为进程提供稳定的运行环境。

三、线程

当今时代大部分电脑的cpu都已经是多核心的了,因此我们可以充分利用这个特点,进行并发执行多个任务,从而提高程序的执行效率。

进行并发编程可以从两个方面:多进程、多线程

0x00 多进程

通过多进程实现并发编程效果非常理想,但是多进程模型会有明显的缺点:太重量、效率不高。(相比多线程)

这是因为进程是资源分配的基本单位,每个进程都有自己的内存空间和系统资源,这就导致创建一个进程需要去申请资源,销毁一个进程需要去释放资源。如果频繁的进行创建/销毁进程,这个时候,开销就不能忽视了~

0x01 多线程

在Java中,更加鼓励使用多线程编程,这样就能很好的解决上述问题。这是因为线程是“轻量级进程”创建/销毁比进程更快

线程不能独立存在,而是依附于进程。一个进程可以包含一个线程,也可以包含多个线程。一个进程在最开始的时候,至少要有一个进程,也可以根据需要创建多个线程,从而实现并发编程。

一个进程中,可以有多个线程,每个线程都是可以独立的进行调度的,而调度是根据pcb属性来进行的,因此一个拥有多个线程的进程需要使用多个pcb(pid、内存指针、文件描述符表共用一份)来进行维护。

一个进程的多个线程之间,由于共用一份内存空间和文件资源,创建线程的时候,不需要再重新申请资源了,直接复用之前操作系统分配给进程的资源,省去了资源分配的开销,提高了效率

总得来说就是:

1、一个进程拥有一个或多个线程

2、每个线程使用一个pcb来表示,每个线程都可以独立的去CPU上调度执行,所以pcb中的状态、上下文、优先级、记账信息是独立的,线程是调度执行的基本单位。

3、pcb中的pid、内存指针、文件描述符表共用一份,所以创建线程的时候不需要再申请资源,创建的效率更高。

0x02 多线程的问题

1、我们知道可以通过创建额外的线程来帮我们提升效率,假设我们的线程创建的非常多,此时还能进一步提高效率吗?肯定是不能的,反而会因为要调度的线程太多了,导致调度的开销太大,进而降低了效率。

2、如果两个线程同时去争抢某个资源,这就会引起冲突,导致线程不安全。

3、如果某个线程生气了,出现了异常,此时如果没有妥善的处理,就容易使整个进程崩溃。例如:在程序中越界了,你却没有catch。

0x03 进程与线程的区别

1、进程包含线程,一个进程可以有一个或多个线程。

2、同一个进程的线程之间共用一份资源,不用再申请资源。

3、线程是资源分配的基本单位,进程是调度执行的基本单位。

4、进程和线程都是用来实现并发编程的,但是线程比进程更轻量、高效。

5、进程拥有独立性,一个进程挂了不会影响到别的进程。但一个进程内的线程挂了可能会影响到其他线程。

四、多线程编程

如何使用Java进行多线程编程?

线程是操作系统的概念,操作系统提供了一些API,可以用来操作线程,Java针对操作系统的API进行了封装,因此Java程序员只需要掌握一套API就可以到处运行了~~

0x00 如何创建线程

在Java中通过创建Thread类对象,来进行多线程操作。如果我们要想自定义一个线程的话,我们需要继承Thread。

class MyThread extends Thread{
}

一个线程跑起来,是从它的入口方法,也就是从run方法开始执行的,类似于一个Java程序的入口是main方法一样,只不过运行一个Java进程,会创建一个主线程,而主线程的入口是main方法

我们观察源码会发现,Thread类实现了Runnable接口,而这个接口内部就定义了一个抽象的run方法。

所以,我们只需要重写一下run方法,在里面写线程的代码逻辑。不过重写了run方法只是定义了方法,要想真正启动一个线程,我们还需要调用start方法(在Thread类中定义了)。

class MyThread extends Thread{
    @Override
    public void run() {
        //当前线程执行的入口
        System.out.println("线程 启动!");
    }
}
​
public class Test {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start();
    }
}

小结:

start、run方法都是Thread的成员方法run方法只是描述了线程的入口,该执行什么代码。start方法则是真正的调用了系统API,在系统中创建了线程,再让线程调用run方法。

0x01 线程如何执行

现有如下代码:

class MyThread extends Thread {
    @Override
    public void run() {
        //当前线程执行的入口
        while(true){
            System.out.println("myThread线程在执行");
​
            try {
                sleep(200);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}
​
public class Test {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start();
​
        while(true){
            System.out.println("main线程在执行");
​
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

运行结果如下:

通过观察,我们发现,在主线程开启一个线程的时候,主线程并不过阻塞,依旧会往下执行,而且线程执行的顺序也不是完全按照线程调用顺序,而是根据调度来决定

如果将调用start方法,改成调用run方法呢?

由于没有使用start方法,程序不会创建一个线程,而是直接去调用run方法,直到调用完run方法后,才会回来继续往下执行main方法


多线程运行的时候,可以使用jconsole来观察到进程里的多线程情况。jconsole是jdk中的一个工具。

0x02 线程的创建方式

创建一个线程其实很简单,只需要你重写run方法(写自己的代码逻辑),和调用start方法。而重写run方法可以通过继承Thread、实现Runnable接口、通过匿名内部类、使用lambda表达式........

1、继承Thread类

class MyThread extends Thread {
    @Override
    public void run() {
    }
}
​
public class Test {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start();
    }
}

2、实现Runnable接口

class MyThread implements Runnable {
    @Override
    public void run() {
    }
}
​
public class Test {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        //由于Runnable接口没有start方法,所以得通过Thread的构造方法来创建。
        Thread thread = new Thread(myThread);
        thread.start();
    }
}

3、通过匿名内部类

public class Test {
    public static void main(String[] args) {
        Thread thread = new Thread(){
            @Override
            public void run() {
                //程序代码
            }
        };
        thread.start();
    }
}

4、基于lambda表达式

public class Test {
    public static void main(String[] args) {
        Thread thread =  new Thread(() -> {
            //程序代码
        });
        thread.start();
    }
}

五、Thread类及常用的方法

0x00 Thread类的构造方法

方法名说明
Thread()创建线程对象
Thread(Runnable target)使用Runnable对象创建线程对象
Thread(String name)创建线程对象,并命名
Thread(Runnable target, String name)使用Runnable对象创建线程对象,并命名
Thread(ThreadGroup grop, Runnable target)将线程分组管理

0x01 Thread的几个常用的属性

属性获取方法说明
IDgetId()获取线程的身份标识符ID
名称getName()获取线程的名称
状态getState()获取线程的状态
优先级getPriority()获取线程的优先级
是否是后台线程isDaemoin()判断是否是后台线程
是否存活isAlive()判断是否存活
是否被中断isInterrupte()判断是否中断

补充

1、ID是线程的身份标识,标识一个进程中的唯一线程,这个ID是Java给你分配的,并不是系统api提供的线程ID。

2、优先级,在理论上可以使优先级高的线程更容易被调度到,但站在应用程序的角度,很难察觉到。

3、后台线程,也叫做守护线程,与之对应的是前台线程。

前台线程:当所有的前台线程执行结束后,进程才会结束执行

后台线程:后台线程的结束与否不会影响进程的结束。

4、判断是否存活,是判断内核线程中是否还有,而不是判断Thread对象。Thread对象的生命周期往往比线程长,线程没了,Thread对象还在。

0x02 中断一个线程

在C++中可以直接终止一个正在运行的线程,但是在Java中是不可以的,因此设计思路就是让这个线程中的run方法尽快的结束。如下:

1、通过共享的标记,让线程退出

2、通过interrupt来中断线程

示例1:

public class Test2 {
    private static boolean Quit = false;
​
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            while(Quit){
                return
            }
        });
        
        thread.start();
        
        //手动设置退出
        Quit = true;
    }
}

上述方式是通过修改一个共享的变量,进行控制线程的运行的。但有如下缺点:

1、需要手动创建变量,比较麻烦。

2、当修改了变量,理应让线程立马去执行完剩下的代码,但如果线程此时sleep了,会到导致这个线程还要再睡一会,不符合我们的设计初衷(让线程尽快的执行结束)。


示例2:

public class Test2 {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
           //currentThread() 返回的是 哪个线程调用,就返回哪个线程对象
           //isInterrupted() 判断是否中断
           while(!Thread.currentThread().isInterrupted()){
               System.out.println("线程正在工作");
               try {
                   Thread.sleep(600);
               } catch (InterruptedException e) {
                  e.printStackTrace();
               }
           }
        });
        thread.start();
        Thread.sleep(500);
        //让线程中断
        thread.interrupt();
    }
}

运行结果如下

抛出了一个异常,这是因为这个线程在sleep,然后interrupt()唤醒了线程,这就触发了sleep的异常。但我明明是让线程中断的,但是线程依然在工作,这是为什么呢?

解释:

sleep的线程被interrupt唤醒后,此时sleep方法会抛出异常,同时将"终止标识符"清除(在Thread对象内部有一个终止标识符,interrupt方法本质是使用过设置终止标识符来退出程序的)。

为什么要这样设定?是因为Java期望当线程收到终止信号的时候,能够自己决定接下来如何处理。也就是说如果在catch中捕获到异常,程序员可以自己决定接下来要干嘛,如果没有捕获到,run方法尽快结束就行。

0x03 线程等待

用来控制线程结束的顺序,让一个线程等待另一个线程执行结束,再继续执行。

方法说明
join()等待线程结束
join(long millis)等待线程结束,最多等 millis 毫秒
join(long millis, int nanos)等待线程结束,精度可以达到纳秒

说明:

thread.join() 工作过程:如果thread线程正在运行,那调用join的线程就会阻塞,一直阻塞到thread线程执行结束,如果thread线程已经执行结束了,调用join的线程就不会阻塞,直接继续执行

0x04 线程状态

状态说明
NEWThread对象创建了,但还没调用start方法
TERMINATEDThread对象还在,但内核中的线程已经没了
RUNNABLE可运行的,线程已经在cpu上执行了/线程正在排队等待上cpu执行
TIME_WAITING阻塞,由于sleep这种固定时间的方式产生阻塞
WAITING阻塞, 由于wait这种不固定时间的方式成的阻塞
BLOCKED阻塞,由于锁竞争导致的阻塞

0x05 线程安全

线程安全是多线程中最重要最复杂的部分。可能用一份代码在单线程的环境下执行是正确的,但在多线程环境中就不一定了。

示例:

public class Test4 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for(int i = 0; i < 10000; i++){
                count++;
            }
        });
​
        Thread t2 = new Thread(() -> {
            for(int i = 0; i < 10000; i++){
                count++;
            }
        });
​
        t1.start();
        t2.start();
​
        t1.join();
        t2.join();
​
        System.out.println("count: " + count);
    }
}

在逻辑上,count应该自增了2w次,最终count的值应该为2w,然而结果却不是2w,而且几乎每次运行的结果都不相同

解释:

这是因为count++ 操作,本质上是分成三步的

1、load 把数据从内存中读到cpu寄存器中

2、add 把寄存器中的数据进行+1

3、save 把寄存器中的数据,保存到内存中。

如果是多个线程执行的话,由于线程之间的调度顺序是随机的,并不确定,就会导致出现问题

如:当第一个线程正在进行第一个操作的load的时候,第二个线程已经完成了第二、三、四的操作,此时第一个线程再进行第一个操作的add的时候,从寄存器中读取到的数据是0,而非3,因此就会出现错误。

总结:

产生线程安全问题的原因:

1、操作系统中,线程的调度顺序是随机的(抢占式执行)

2、不同线程,最对同一个变量进行修改

3、修改操作,不是原子的,即某个操作必须一起全部完成。

4、内存可见性问题

5、指令重排序问题


那要如何保证代码一定准确呢?答案是加锁

synchronized(对象名){
​
}

注意:

() 中需要表示一个用来加锁的对象,这个对象是啥不重要,重要的是通过这个对象来区分两个线程是否在竞争同一个锁。如果两个线程是在针对同一个对象加锁,就会有锁竞争,如果不是针对同一对象加锁,就不会有锁竞争,而此时的并发程度最高,但是不能保证正确。

{}内的代码就是要执行的内容了。

当一个线程拿到了这把对象锁之后,另外一个线程就得阻塞,等待上一个线程释放锁,之后再进行竞争这把锁。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值