【JavaEE】_1.多线程(1)

目录

1.操作系统

2. 进程

3. CPU分配——进程调度

3.1  操作系统对进程的管理

3.2 PCB的属性

3.2.1  基础属性

3.2.2  实现进程调度的属性

4. 内存分配——内存管理

4.1 虚拟地址空间

4.2 进程间通信

5. 线程

5.1 线程的概念

5.2 创建与使用多线程

5.2.1 方式1:继承Thread类

5.2.2 方式2: 实现Runnable接口

5.2.3 以上两种创建线程方式的对比

5.3 多线程的优势-增加运行速度

5.4 Thread类及其常用方法

5.4.1 Thread类常用构造方法

5.4.2 Thread类的几个常见属性

5.4.3 启动一个线程

5.4.4 中断一个线程

5.4.5 等待一个线程

5.4.6 获取当前线程引用

5.4.7 休眠当前线程

5.5 线程的状态与转移

5.5.1 线程的状态

 5.5.2 线程状态的转移

5.6 线程安全

5.6.1 线程不安全问题

5.6.2 线程不安全的原因

5.6.3 解决线程不安全问题

5.7 synchronized关键字——监视器锁monitor lock

5.7.1 synchronized的特性

5.7.2 synchronized的使用

5.7.3 Java标准库中的线程安全类


1.操作系统

【JavaEE】_1.多线程(1)_第1张图片

操作系统对下要管理各种硬件设备,对上要给各种软件提供稳定的运行环境;

操作系统有两个基本功能:

(1)防止硬件被时空的应用程序滥用;

(2)向应用程序提供简单一致的机制来控制复杂而又通常大相径庭的低级硬件设备;

2. 进程

.exe都称为“可执行文件”,在未运行时,这些文件不会对系统产生任何的影响,开始运行时,操作系统就会把这个.exe加载到内存中,并且执行.exe内部的一些指令(.exe内存储了很多程序对应的二进制指令),将这些正在运行的可执行文件称为进程,有时也被称为任务;

3. CPU分配——进程调度

3.1  操作系统对进程的管理

(1)先描述一个进程(明确一个进程上的相关属性);

一般都是使用C或C++编写PCB(进程控制块)实现的;

(2)再组织若干个进程(使用一些数据结构组织描述进程的信息,方便进行增删查改);

典型实现方式是使用双向链表将每个进程的PDB串联起来的;

故而:创建进程即创建出PCB后将其加入双向链表;

销毁进程即找出PCB后从链表上删除;

查看任务管理器即遍历链表;

3.2 PCB的属性

3.2.1  基础属性

(1)pid(进程id):进程的身份标识;

(2)内存指针:指明该进程要执行的指令和代码在内存的位置,和这个进程执行中依赖的数据的位置;比如运行一个.exe可执行程序时,操作系统会将.exe加载到内存中变成进程,进程与这块内存空间是绑定的,即此块内存中包含.exe中存储的内容,.exe中存储着进程要执行的二进制指令(编译器生成)与数据;

(3)文件描述符表:文件描述符表是一个结构体数组,每一个结构体对应一个文件的相关信息,进程每次打开一个文件,文件描述符表上就会多增加一项;

一个进程只要一启动,不论代码中是否编写了打开或操作文件的代码,都会默认打开三个文件,分别为标准输入(System.in),标准输出(System.out)以及标准错误(System.err),将文件描述符表的下标称为文件描述符;

3.2.2  实现进程调度的属性

进程调度是理解进程管理的重要话题,现在的操作系统一般都是“多任务操作系统”,即一个系统在同一时间执行了多个任务。单任务操作系统是不需要考虑进程调度问题的;

系统上的任务数量是成十上百的,而CPU只有几个核,这就涉及到了进程调度的问题:并行与并发是进程调度中的一堆重要概念:

在宏观上,并行与并发看起来都是多个任务在同时进行,而微观上则不同:

并行:微观上两个CPU核心同时执行两个任务的代码;

并发:微观上一个CPU核心快速切换交替执行多个任务;

这里微观上的区分是操作系统自行调度的结果,宏观上我们无法区分,故而在编程时我们也不具体区分,通常使用并发统称代替并发与并行;

(1)状态:描述当前这个进程将任何调度,主要考虑以下两种状态:

就绪状态:随时可以去CPU上执行;

阻塞状态或睡眠状态:暂时不可以去COU上执行

(2)优先级:分配时间的先后、多少的问题;

(3)上下文:表示上次进程调度处CPU时当时程序的状态,下次进程上CPU时,就可以恢复之前的状态,然后继续往下执行;

即进程被调度出CPU之前要先把CPU中所有的寄存器中的数据都保存到内存中(PCB的上下文字段中),类似于存档,下次进程再上CPU时就可以从刚才的内存中恢复这些数据到寄存器中,相当于读档;

(4)记账信息:统计每个进程的执行时长、指令、等待执行时间等等,可以为进程调度提贵不贵指导依据;

4. 内存分配——内存管理

进程的调度就是操作系统在考虑CPU资源如何给各个进程分配;

4.1 虚拟地址空间

内存资源的分配则与虚拟地址空间有关,现代操作系统能做到进程独立性就依赖于虚拟地址空间;

不同的进程使用内存中的不同区域,互相之间互不干扰;

4.2 进程间通信

但是在实际工作中,进程之间又是是需要相互交互的,故而操作系统也提供了公共空间,一个进程将数据先放到公共空间上,另外一个进程再进行读取,称为“进程间通信”;

在操作系统中提供的公共空间有很多种,各有特点,如不同的大小、速度等等;

目前最主要使用的进程间通信主要是两种:文件操作与网络操作;

5. 线程

5.1 线程的概念

进程的存在是由于系统的多任务执行需求,这也要求程序员进行并发编程;

使用多进程是完全可以实现并发编程的,但如果要频繁地创建或销毁(如分配、销毁内存或文件)以及频繁地调度进程,资源的申请和释放不仅低效,成本也非常高;

为了解决这个问题,通常会通过两个方式:

(1)进程池:效率有一定提高,但进程池中的闲置进程不使用的时候仍然在消耗系统资源,故而使用进程池的系统资源消耗是非常大的;

(2)线程:线程比进程更轻量,每个线程也能够执行一个任务(代码),也能够并发编程;

创建、调度、销毁一个线程的成本相比进程而言要低很多,在Linux上也把线程称为轻量级进程,

进程重量重在资源的申请和释放,线程则是包含在进程中的,一个进程中的多个线程共用同一份资源(同一份内存+文件),只有在创建进程的第一个线程时,由于需要分配资源,成本是相对较高对的,后续在这个进程中再创建其他线程的成本都比较低;

但是并非线程越多越好,如果线程过多,就会存在资源竞争导致速度受限;

注:进程与线程的区别与联系?

(1)进程包含线程,一个进程里可以包含一个线程,也可以包含多个线程;

(2)进程和线程都是为了处理并发编程场景,但进程频繁创建、调度、释放时效率较低,消耗较大;而线程由于少了申请释放资源的过程,故而更轻量,创建、调度、释放都效率更高,消耗更少;

(3)操作系统创建进程需要给进程分配资源,故而进程是操作系统分配资源的基本单位;

操作系统创建线程是要在CPU上调度执行,故而线程是操作系统调度执行的基本单位;

(4)进程具有独立性,每个进程都由各自的虚拟地址空间,进程之间互不影响;

同一个进程中的多个线程共用同一个内存空间,线程之间可能会互相影响;

5.2 创建与使用多线程

5.2.1 方式1:继承Thread类

java标准库提供了一个Thread类来表示、操作线程,Thread类也可视为是java标准库提供的API;

创建好的Thread实例和操作系统中的线程是一一对应的关系;

操作系统提供了一组关于API(C语言),java对于这组API进一步封装形成了Thread类;

示例代码1:单线程创建示例

class MyThread extends Thread{
    @Override
    public void run(){
        System.out.println("Hello Thread.");
    }
}
public class Demo1 {
    public static void main(String[] args) {
        Thread t = new MyThread();
        t.start();
    }
}

注:(1)通过Thread类创建线程有很多种写法,最简单的就是创建子类继承Thread并且重写run方法;

(2)run方法中描述该线程要执行哪些代码,由于每个线程都是并发执行的,因此需要告知每个线程要执行的代码内容,run方法中的逻辑是在新创建出的线程中被执行的代码;

(3)start方法的调用代表着在系统中真正创建了线程,此时才开始执行上文的run操作;

(4)这里创建线程是在同一个进程中创建的;

(5)线程之间是并发进行的:

(6)线程强制中断异常是多线程中最常遇到的异常之一:

【JavaEE】_1.多线程(1)_第2张图片

(7)Thread是java.lang中的类,是不需要导入包的,类似的还有String也是不需要导入的;

 示例代码2:多线程创建示例

class MyThread extends Thread{
    @Override
    public void run(){
        while(true){
            System.out.println("Hello Thread");
            try {
                Thread.sleep(1000);
                //休眠:强制使线程进入阻塞状态  单位为ms
                //即1s内这个线程不会到cpu上执行

            }catch(InterruptedException e){
                e.printStackTrace();
            }
        }
    }
}
public class Demo1 {
    public static void main(String[] args) {
        Thread t = new MyThread();
        t.start();
        while(true){
            System.out.println("Hello Main");
            try {
                Thread.sleep(1000);
            }catch(InterruptedException e){
                e.printStackTrace();
            }
        }
    }
}

截取部分输出结果如下:

【JavaEE】_1.多线程(1)_第3张图片

注:(1)一个进程中至少会有一个线程,在一个java进程中也至少会有一个调用main方法的线程,只是该线程是系统自动生成的而非手动创建的,此时我们手动创建的t线程与自动创建的main线程就是并发执行的关系,这两个线程从宏观上看该输出结果就是同时执行的;

(2)两个线程都是打印一条语句后休眠1s,当1s结束后,系统先唤醒哪个线程是随机的,即对于操作系统来说,内部对线程之间的调度顺序在宏观上也可以认为是随机的,这种调度方式也称为抢占式执行;

(3)sleep是一个毫秒级休眠语句,并没有那么精确,比如sleep(1000)的含义是1000ms之内不能上CPU,而不是1000ms之后准时上CPU,故而结束阻塞状态的具体时间是不确定的,这与线程之间的调度是随机的也是彼此互相印证的;

(4)start方法用于启动线程;

示例代码3:使用匿名内部类

public class Demo3 {
    public static void main(String[] args) {
        //1.创建一个匿名内部类继承自Thread类
        //2.重写run方法
        //3.new这个匿名内部类的实例
        Thread t = new Thread(){
            public void run(){
                System.out.println("Hello Thread.");
            }
        };
        t.start();
    }
}

5.2.2 方式2: 实现Runnable接口

创建一个类实现Runnable接口,再创建Runnable实例传给Thread实例;

代码示例1:实现Runnable接口创建线程

//Runnable 就是在描述一个任务
class MyRunnable implements Runnable{
    @Override
    public void run(){
        System.out.println("Hello");
    }
}
public class Demo2 {
    public static void main(String[] args) {
        Thread t = new Thread(new MyRunnable());
        t.start();
    }
}

通过Runnable来描述任务的内容,进一步地再把描述好的任务交给Thread实例;

代码示例2:使用匿名内部类

public class Demo4 {
    public static void main(String[] args) {
        //1.new Runnable的匿名内部类
        //2.将new的Runnable实例传给Thread的构造方法
        Thread t = new Thread(new Runnable(){
            public void run(){
                System.out.println("Hello Thread.");
            }
        });
        t.start();
    }
}

需要将new 的Runnable的实例传递给Thread,故而需要包含其重写的run方法;

代码示例3:使用lambda表达式

public class Demo5 {
    public static void main(String[] args) {
        Thread t = new Thread(()->{
            System.out.println("Hello Thread.");
        });
        t.start();
    }
}

lambda表达式就是一个匿名方法,()表示方法参数,->表示是一个lambda,{}中编写方法内容;

5.2.3 以上两种创建线程方式的对比

通常认为Runnable方式会更好一点,能够做到让线程与线程执行的任务更好地进行解耦;

编写代码通常希望高内聚、低耦合。

Runnable只是描述了一个任务,但是任务的执行方式是进程、线程、线程池还是协程来执行,Runnable内部并不作涉及,Runnable内部的代码也不涉及;

5.3 多线程的优势-增加运行速度

多线程编程的优势显著体现在可以提高任务完成的效率:

如现有两个整数变量,分别对这两个变量自增10亿次,分别使用一个线程与两个线程进行演示:

public class Demo6 {
    private static final long count = 10_0000_0000;

    public static void serial(){  //串型执行
        //记录程序执行时间
        long beg = System.currentTimeMillis();
        long a = 0;
        for(long i=0;i{
            long a = 0;
            for(int i=0;i{
            long b = 0;
            for(int i=0;i

输出结果为:

【JavaEE】_1.多线程(1)_第4张图片

注:(1)增加线程并非一定会达到翻倍的速度提升,因为两个线程在底层到底是并行执行还是并发执行并不确定, 底层微观真正并行执行的时候,效率才会有显著提升;

(2)当count不够大时,反而可能会导致程序执行速度更慢,因为创建线程本身也需要时间开销,此时代码的执行时间反而更多地消耗在了创建线程上;

(3)多线程适合应用于CPU密集型的程序,当程序需要进行大量的计算时,使用多线程就可以更充分地利用CPU的多核资源;

5.4 Thread类及其常用方法

5.4.1 Thread类常用构造方法

方法

说明

Thread() 创建线程对象
Thread(Runnable target) 使用Runnable对象创建线程对象
Thread(String name) 创建线程对象并命名
Thread(Runnable target,String name) 使用Runnable对象创建线程对象并命名
Thread(ThreadGroup group,Runnable target) 使用线程组对线程分组管理

前两个构造方法前文已经使用,此处不再赘述,此处主要展示带有命名的线程对象的创建:

public class Demo1 {
    public static void main(String[] args) {

        Thread t1 = new Thread(()->{
            while(true){
                System.out.println("Hello Thread1.");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }, "Thread t1");
        t1.start();
        Thread t2 = new Thread(()->{
            while(true){
                System.out.println("Hello Thread2.");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }, "Thread t2");
        t2.start();
    }
}

运行代码并根据jdk安装路径打开bin文件找到jconcole.exe文件:

【JavaEE】_1.多线程(1)_第5张图片

打开在本地进程中连接对应进程:
【JavaEE】_1.多线程(1)_第6张图片

进入界面选择进程后,就可以看到正在运行的线程,点击线程还可以显示线程的执行位置:

【JavaEE】_1.多线程(1)_第7张图片

注:(1)给Thread命名仅便于调试时对线程进行区分,对于线程执行没有其他影响;

(2)java进程启动后不只有我们手动编写的线程,还有一些JVM自己创建的线程用于其他的不同工作,比如收集统计调试信息,监听网络链接等等;

5.4.2 Thread类的几个常见属性

属性 获取方法
ID getId()
名称 getName()
状态 getState()
优先级 getPriority()
是否后台程序 isDaemon()
是否存活 isAlive()
是否被中断 isInterrupted()

注:(1)ID是线程的唯一标识,不同线程不会重复;

(2)获得的名称也就是构造时指定的名称,主要用于调试时便于查看线程状态;

(3)状态表示当前线程所处的状态,后续详细介绍;

(4)优先级高的线程理论上来说更容易被调度到;

(5)后台线程:

①  如果一个线程是后台线程,就不影响进程退出,main方法执行完毕后就会结束进程,并强行终止后台线程;

如果一个线程是前台线程,即使main方法执行完毕,也必须等前台线程执行完毕1才能结束进程,JVM会在一个进程的所有非后台线程结束后才会结束运行

②  代码里手动创建的线程(包括main线程)默认是前台线程,其他的jvm自带的线程都是后台线程,也可以使用setDaemon将一个前台线程设置为后台线程;

③ 注意区别线程调度与后台线程;

(6)是否存活可以理解为:操作系统中对应的线程是否正在运行;

Thread t 对象的生命周期和内核中对应的线程生命周期并不完全一致,t对象被创建后,在调用start方法之前,系统中是没有对应线程的,在run方法执行完毕后,系统中的线程就销毁了,但是t对象还可能存在;

【JavaEE】_1.多线程(1)_第8张图片

在调用start之后,run执行完之前,isAlive就是返回true,

如果是调用start之前,run执行完之后,isAlive就是返回false;

(7)线程的中断问题,后续详细介绍;

5.4.3 启动一个线程

public class Demo2 {
    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);
            }
        }
    }
}

运行以上代码,截取部分输出结果如下:

【JavaEE】_1.多线程(1)_第9张图片

将上文代码的t.start()语句更换为t.run()语句再试运行,截取部分输出结果如下:

【JavaEE】_1.多线程(1)_第10张图片

因为run方法只是一个普通的方法,在main线程中调用run,其实并没有创建心得线程,上文中的循环语句依然是在main线程中执行的,在一个线程中,代码就会按照从前至后的顺序进行运行,此时就只会在第一个循环结构中一直打印Hello Thread.;

可以通过修改循环条件进行验证:

public class Demo2 {
    public static void main(String[] args) {
        Thread t = new Thread(()->{
            for(int i=0;i<3;i++){
                System.out.println("Hello Thread.");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        t.run();

        for(int i=0;i<2;i++){
            System.out.println("Hello Main.");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

再运行代码,输出结果为:

【JavaEE】_1.多线程(1)_第11张图片  

注:run方法只是一个遵循串型执行的、描述任务内容的普通调用方法;

调用start方法,才会在操作系统的底层创建一个线程;

5.4.4 中断一个线程

中断一个线程的关键在于使当前线程对应的run方法执行完

但对于特殊的main线程来说,需要main方法执行完线程才完;

方法1:手动设置标志位(自己创建的变量),来控制线程是否要执行结束:

// 通过设置标志位中断一个线程
public class Demo2 {
    private static boolean flag = true;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            while(flag){
                System.out.println("Hello Thread.");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
        //在主线程中通过更改flag变量的取值来操作t线程是否结束
        //当flag为假时不再进入循环
        Thread.sleep(3000);
        flag = false;
    }
}

输出结果为:

【JavaEE】_1.多线程(1)_第12张图片

注:(1)t线程开始执行后,当3s时间截止后,标志位flag被修改,此时t线程run方法所在循环被终止,t线程终止,再后的main方法也执行完毕,main线程也终止,同时进程也终止了;

(2)此处因为多个线程共用一个虚拟地址空间,因此main修改的flag与t线程判定的flag是同一个值;

(3)以上写法并不严谨,后续再进行介绍,同时由于手动设置标志位来中断一个线程不能及时响应,尤其是在sleep休眠时间比较久时,故而引出中断一个线程的第二种方法;

方法2:使用Thread内置的标志位:

 判断线程是否被中断:Thread.currentThread().isInterrupted()实例方法,其中currentThread可以获取到当前线程的对象的引用;

②  设置线程中断位置:t.interrupt()

(更推荐② 一个代码中的线程可能有很多,随时哪个线程都有可能终止,①方法表示一个程序只有一个标志位,但是②方法判定的标志位是Thread的普通成员,每个实例都有自己的标志位;)

代码示例1:

public class Demo4 {
    public static void main(String[] args) {
        Thread t = new Thread(()->{
            while(!Thread.currentThread().isInterrupted()){
                System.out.println("Hello Thread.");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
        //在主线程中调用interrupt方法来中断t线程
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //中断t线程
        t.interrupt();
    }
}

运行结果如下:

【JavaEE】_1.多线程(1)_第13张图片

 注:(1)调用interrupt()方法可能产生两种情况:

如果调用线程处于就绪状态,则是设置线程标志位为true;

如果调用线程处于阻塞状态(sleep),interrupt方法就会强行打破休眠,则sleep会触发InterruptException异常,导致线程从阻塞状态被唤醒,将线程从sleep中被唤醒时,又会将原先设定的标志位再设置回false,即清空了标志位;

不只是sleep方法可以将线程意外中断后清除标志位,像join、wait等可以造成线程堵塞的方法都有类似于清除标志位的设定;

(2)上文代码一旦出发了异常就会进入catch语句,在catch语句中,就打印出当前异常位置的代码调用栈,打印完毕后继续运行;

这显然不是我们期待的结果,故而需要修改:

代码示例2:

public class Demo3 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            while(!Thread.currentThread().isInterrupted()){
                System.out.println("Hello Thread.");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    //触发异常后稍后几秒再终止t线程
                    try{
                        Thread.sleep(1000);
                    }catch(InterruptedException ex){
                        ex.printStackTrace();
                    }
                    //触发异常后立刻退出循环
                    break;
                }
            }
        });
        t.start();
        Thread.sleep(3000);
        t.interrupt();     //通知终止t线程
    }
}

在t线程抛出Interrupted异常后立刻停止工作,输出结果为:

【JavaEE】_1.多线程(1)_第14张图片

也可看出interrupt唤醒sleep中的线程后,要将标志位重新置回的意义:

t.interrupt()只是main线程通知t线程终止,而t线程是否终止取决于其本身,重新将标志位置回意味着可以在t线程内部选择继续执行、终止、稍后终止,否则t线程只能立刻终止。

5.4.5 等待一个线程

多个线程之间的调度顺序是不确定的,线程之间的执行顺序是调度器无序、随机执行的,有时我们需要控制线程之间的执行顺序,线程等待就是控制线程结束先后顺序的重要方法,哪个线程调用join()方法,哪个线程就会阻塞等待对应线程的run方法执行结束为止。

代码示例1:当被等待的线程尚未执行完毕时:

public class Demo4 {
    public static void main(String[] args) {
        Thread t =  new Thread(()->{
           for(int i=0;i<3;i++){
               System.out.println("Hello Thread.");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
        });
        t.start();
        System.out.println("Hello Main");
        //让main线程等待t线程的run方法执行完毕
        try {
            t.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Hello Main");
    }
}

输出结果为:

【JavaEE】_1.多线程(1)_第15张图片

注:(1)上文代码是main线程调用join()方法,是针对t这个线程对象调用的,此时就是让main等待t,调用join()方法后,main线程就会进入阻塞状态,直到t的run()方法执行完毕后,main线程才会继续执行; 

(2)干预两个线程的执行顺序体现在:通过线程等待控制先让t结束,main后结束;

(3)注意区别干预线程执行顺序与优先级的区别:优先级是操作系统内部内核进行线程调度使用的参考量,在用户层面代码不能完全干预或控制,线程执行顺序是代码中控制的先后顺序;

代码示例2:当被等待的线程已经执行完毕时:

public class Demo4 {
    public static void main(String[] args) {
        Thread t =  new Thread(()->{
           for(int i=0;i<3;i++){
               System.out.println("Hello Thread.");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
        });
        t.start();
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Hello Main");
        //让main线程等待t线程的run方法执行完毕
        try {
            t.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Hello Main");
    }
}

输出结果为:

【JavaEE】_1.多线程(1)_第16张图片

如果被等待的线程在其他线程尚未执行到等待该线程的语句时该线程已经执行完毕,则join直接返回;

注:join()操作默认情况下是持续等待的,为了避免这种机制带来的麻烦,join提供了另外一个版本,可以设定最长等待时间(等待时间上限),具体内容请看示例3:

代码示例3:指定join的等待上限:

public class Demo4 {
    public static void main(String[] args) {
        Thread t =  new Thread(()->{
           for(int i=0;i<5;i++){
               System.out.println("Hello Thread.");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
        });
        t.start();
        System.out.println("Hello Main");
        //让main线程等待t线程的run方法执行完毕
        try {
            t.join(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Hello Main");
    }
}

输出结果为: 

【JavaEE】_1.多线程(1)_第17张图片

上文代码t.join(2000)代表:main线程进入阻塞状态等待t线程run方法执行完毕,如果2s之内t线程执行结束了,此时join直接返回,如果3s之后t线程还未结束,join也直接返回;

5.4.6 获取当前线程引用

Thread.currentThread()是一个静态方法,能获取当前线程实例的引用,哪个线程调用则获取哪个线程实例的引用;

代码示例1:通过继承Thread类创建线程:

public class Demo6 {
    public static void main(String[] args) {
        Thread t = new Thread("Thread t"){
            @Override
            public void run(){
                System.out.println(Thread.currentThread().getName());
            }
        };
        t.start();
        //main线程
        System.out.println(Thread.currentThread().getName());
    }
}

输出结果为:

由于本方式是通过继承Thread类来创建接口的,故而也可以通过this.getName()方式获取当前类的对象的引用:

public class Demo6 {
    public static void main(String[] args) {
        Thread t = new Thread("Thread t"){
            @Override
            public void run(){
                //System.out.println(Thread.currentThread().getName());
                System.out.println(this.getName());
            }
        };
        t.start();
        //main线程
        System.out.println(Thread.currentThread().getName());
    }
}

代码示例2:通过实现Runnable接口创建接口:

这种方式创建线程,则不能通过this.getName()方法获取当前对象的引用:

Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println(this.getName());
            }
        });

由于此时this不是指向Thread类型了,而是指向Runnable,Runnable只是一个任务,没有name属性;

只能通过Thread.currentThread().getName()方式获取当前对象的引用:
 

        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                Thread.currentThread().setName("Thread t");
                System.out.println(Thread.currentThread().getName());
            }
        });
        t.start();

输出结果为:

5.4.7 休眠当前线程

在前文已经介绍过:对于一个线程的进程,在系统中是通过PCB描述的,通过双向链表组织的;

对于一个有多个线程的进程,每个线程都有一个PCB,一个进程对应的就是一组PCB,PCB上有一个tgroupId,这个id就相当于是进程的id,同一个进程中的若干个tgroupId是相同的;

PCB即process control block进程控制块,其实在Linux内核是不区分进程与线程的,只有在程序员写应用程序代码时才会进行区分,Linux内核只识别辨认PCB,在内核中线程被称为轻量级进程;

实际在操作系统中调度线程的时候,会从就绪队列中挑选合适的CPB到CPU上运行,而执行了sleep()或join()语句的PCB位于阻塞队列就无法上CPU,只有当休眠结束,再次进入就绪队列才会在后续上CPU被执行;

让线程休眠本质就是让该线程不参与调度了,如令该线程休眠1000ms,其实在该线程从阻塞队列被迁移回就绪队列后并不会被立刻调度,往往令休眠的线程的休眠时间要超过手动设置的时间;

5.5 线程的状态与转移

前文已经介绍过进程的状态:就绪状态与阻塞状态;

这个状态决定了系统调度进程的态度,但这只针对单线程进程的情况;

但实际中更常见的进程是多线程进程,此时的状态是绑定在线程上的;

其实在Linux操作系统中,PCB是与线程对应的,一个进程对应一组PCB;

“就绪”与“阻塞”状态都是针对系统层面上线程的状态,在java的Thread类中,对于线程的状态又进一步细化了;

5.5.1 线程的状态

(1)NEW:安排了工作但还未开始行动:

Thread对象创建完成但还没有调用start();

即:Thread对象已经创建完成但内核中还没有线程;

是java内部设置的状态,与操作系统中PCB的状态没有联系;

Thread t = new Thread(()->{

    });
    //获取指定线程状态
    System.out.println(t.getState());
    t.start();

输出结果为: 

(2)RUNNABLE:可工作的,又可分成正在工作中和即将开始工作:即就绪状态

就绪状态包括 ①正在CPU上执行与 ②位于就绪队列中随时可以上CPU执行两种情况;

代码中如果没有sleep或其他可能导致阻塞状态的操作,代码大概率是处在RUNNABLE状态的;

Thread t = new Thread(()->{
            while(true){
            //处在一个快速高速循环的状态
            }
        });
        t.start();
        System.out.println(t.getState());

输出结果为:

(3)BLOCKED

表示当前线程在等待锁,导致了阻塞状态(也是属于阻塞的状态之一);

当线程会使用到synchronized对线程进行加锁时会触发线程处于该状态;

后续进行介绍;

(4)WAITING

表示当前线程在等待唤醒,导致了阻塞状态(也是属于阻塞的状态之一);

当线程使用wait等待唤醒时会触发线程处于该状态;

后续进行介绍;

(5)TIMED_WAITING

当代码中调用了sleep或是join(等待时间上限),线程就会进入TIME_WAITING状态,表示当前线程在一定时间内处于阻塞状态(也是属于阻塞的状态之一);

Thread t = new Thread(()->{
            while(true){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
        Thread.sleep(1000);
        System.out.println(t.getState());

 输出结果为:

(6)TERMINATED:工作完成了:

内核中的PCB已经执行完毕销毁了,但Thread对象仍然存在

实际上内核PCB销毁了,代码中的Thread对象也就没有意义了,

此时为了将该Thread对象标识为无效,设定了Terminated状态;

并且一个线程terminated之后不能再重新start;

        Thread t = new Thread(()->{

        });
        t.start();
        //并不确定t线程与main线程哪个先执行完毕
        try {
            Thread.sleep(1000);
            //令main线程休眠1s,此时t线程的状态大概率是Terminated
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(t.getState());

输出结果为:

 5.5.2 线程状态的转移

【JavaEE】_1.多线程(1)_第18张图片

在实际开发过程中经常会遇到程序卡死这样的情况,即一些关键的线程阻塞了,此时分析线程阻塞时就可以从线程状态入手进行分析与修正;

5.6 线程安全

5.6.1 线程不安全问题

线程安全问题是多线程编程必须考虑的重要问题,也因为其难以理解与处理,故而程序员也尝试发明更多的编程模型来处理并发编程,如多进程、多线程、actor、csp等等;

我们知道,操作系统调度线程是抢占式执行,这样的随机性可能会导致程序执行出现一些bug,如果由于这样的调度的随机性使得代码出现了bug,则认为代码是不安全的,如果没有引入bug,则认为代码是安全的;

线程不安全的典型案例:使用两个线程对同一个整型变量进行自增操作,每个线程自增五万次:

class Counter{
    //保存两个线程要自增的变量
    public int count = 0;
    public void increase(){
        count++;
    }
}
public class Demo1 {
    private static Counter counter = new Counter();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
           for(int i=0;i<50000;i++){
               counter.increase();
           }
        });
        Thread t2 = new Thread(()->{
           for(int i=0;i<50000;i++){
               counter.increase();
           }
        });
        t1.start();
        t2.start();
        //在main线程中打印两个线程自增结束后得到的count结果
        //t1、t2执行结束后再打印count结果
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}

运行三次,输出结果与预期并不相符且多次运行多次不同:

          

注:1.t1.join()与t2.join()谁先谁后均可:

线程是随机调度的,t1、t2线程的结束前后是未知的,

如果t1先结束,则先令main线程等待t1结束,待t1结束后再令main线程等待t2结束;

如果t2先结束,仍先令main等待t1结束,t2结束了t1还未结束,main线程仍然在等待t1结束,等t1结束后,t2已经结束了,则此时t2.jion()立即返回;

2.站在CPU角度来看,count++实际上是3个CPU指令:

第一步:将内存中count值加载到CPU寄存器中;(load)

第二步:寄存器中的值将其+1;(add)

第三步:把寄存器中的值写回到内存的count中;(save)

由于抢占式执行,两个线程同时执行这三个指令的时候顺序上充满了随机性,只有当两个线程的三条指令串型执行的时候才会符合预期,只要三条指令出现交错,就会出现错误,如:

【JavaEE】_1.多线程(1)_第19张图片

5.6.2 线程不安全的原因

(1)根本原因:线程是抢占式执行,线程间的调度充满随机性;

(2)修改共享数据:多个线程对同一个变量进行修改操作,才会导致线程不安全问题;

当多个线程分别对不同的多个变量进行操作,或是多个线程对同一个变量进行读操作,都不会导致线程不安全问题;

(3)操作的原子性问题:针对变量的操作不是原子性的,就会导致线程不安全问题,如上文示例中,自增操作其实是3条指令;

当操作是原子性的,如读取变量的值就只对应一条机器指令,就不会导致线程不安全问题;

(4)内存可见性问题:java编译器的优化操作使得在某些情况下线程之间出现信息不同步问题:

如线程t1一直在高速循环进行读操作,线程t2不定时进行修改操作,此时由于t1的高速访问可能无果,就会停止将数据从内存中读至寄存器中再进行读取,而直接从寄存器中读取,此时若t2线程进行修改操作,就会由于内存可见性问题而使两个线程信息不同步,出现安全问题,示例代码如下:

import java.util.Scanner;
public class Demo2 {
    private static int isQuit = 0;
    public static void main(String[] args) {
        Thread t = new Thread(()->{
           while(0 == isQuit){

           }
            System.out.println("Thread t has finished.");
        });
        t.start();
        Scanner scanner = new Scanner(System.in);
        System.out.println("Please input the value of isQuit: ");
        isQuit = scanner.nextInt();
        System.out.println("Thread main has finished.");
    }
}

输出结果为:

【JavaEE】_1.多线程(1)_第20张图片

并未输出"Thread t has finished."说明t线程并未结束; 

(5)指令重排序问题:指令重排序也是编译器优化的一种操作,编译器在某些情况下可能调整代码的先后顺序来提高程序的效率,单线程通常不会出现问题,但在多线程代码中,可能就会误判导致线程安全问题;

5.6.3 解决线程不安全问题

对应上文的线程不安全问题原因,思考解决线程不安全问题的方法:

(1)线程调度的随机性问题:无法从代码层面进行改进的;

(2)多线程修改同一变量问题:部分情况下可调整代码结构,使不同线程操作不同变量;

(3)变量操作的原子性问题:加锁操作将多个操作打包为一个原子性操作;

(4)内存可见性问题:

① 使用synchronized关键字可以保证内存可见性,被synchronied修饰的代码块,相当于手动禁止了编译器的优化;

② 使用volatile关键字可以保证内存可见性,禁止编译器做出上述优化:

import java.util.Scanner;
public class Demo2 {
    private static volatile int isQuit = 0;
    public static void main(String[] args) {
        Thread t = new Thread(()->{
           while(0 == isQuit){

           }
            System.out.println("Thread t has finished.");
        });
        t.start();
        Scanner scanner = new Scanner(System.in);
        System.out.println("Please input the value of isQuit: ");
        isQuit = scanner.nextInt();
        System.out.println("Thread main has finished.");
    }
}

此时输出结果为:

【JavaEE】_1.多线程(1)_第21张图片  

(5)指令重排序问题:synchronized关键字可以禁止指令重排序;

注:synchronized解决多线程修改同一变量问题代码示例:

使用锁后,就将线程间乱序的并发变成了一个串型操作,并发性降低但会更安全;

虽然效率有所降低但相较于单线程程序,还是能分担步骤压力,效率还是较高的;

java中加锁的方式有很多种,最常使用的是synchronized关键字:

class Counter{
    public int count=0;
    synchronized public void increase(){
        count++;
    }
}
public class Demo1 {
    private static Counter counter = new Counter();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
           for(int i=0;i<50000;i++){
               counter.increase();
           }
        });
        Thread t2 = new Thread(()->{
           for(int i=0;i<50000;i++){
               counter.increase();
           }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}

输出结果为:

  

注:(1)在increase()方法前加上synchronized修饰,此时进入方法就会自动加锁,离开方法就会自动解锁;

(2)当给一个线程加锁成功时,其他线程尝试加锁就会触发阻塞等待,此时对应的线程就处于clocked状态;

(3)阻塞状态会一直持续到占用锁的线程解锁为止,时间轴缩略图如下:

【JavaEE】_1.多线程(1)_第22张图片

 (3)synchronized可以保证操作的原子性,保证内存可见性,还可以禁止指令重排序;

5.7 synchronized关键字——监视器锁monitor lock

5.7.1 synchronized的特性

(1)互斥:

前文已经介绍,某个线程执行到某个对象的synchronized中时,其他线程如果也执行到同一个对象,synchronized就会阻塞等待,进入synchronized修饰的代码块相当于加锁,退出synchronized修饰的代码块相当于解锁;

(2)刷新内存:

前文介绍内存可见性时已经提到:synchronized的工作过程是:

① 获得互斥锁,② 从内存拷贝变量的最新内存到寄存器,③ 执行代码,

④ 将更改后的共享变量的值刷新到寄存器,⑤ 释放互斥锁;

故而synchronized也可以保证内存的可见性;

(3)可重入:

同一个线程针对同一个锁连续加两次,如果出现了死锁就是不可重入,不会死锁就是可重入;

(3.1)死锁:

class Counter{
    public int count=0;
    synchronized public void increase(){
        synchronized (this){
            count++;
        }
    }
}

外层先加了一次锁,内层又对同一对象再次加锁,此时由于外层锁需要执行完内部代码才能解锁,而内层锁需要等待已经先锁的外层锁解锁后才能执行,此时就会形成死锁;

(3.2)可重入锁:

为了解决这个问题,JVM内部将synchronized实现为可重入锁。

可重入锁会记录当前占用锁的线程以及加锁次数,线程a第一次加锁成功后,锁内部就会记录当前占用锁的线程为a,同时加锁次数为1,后续线程a再加锁时,进行的加锁操作就非真实的加锁操作而是一个伪加锁,是没有实质影响的,只是将加锁次数增加为2;

代码执行完毕解锁时,会将计数-1,当锁的计数减到0时,才会真的解锁;

可重入锁降低了程序员的编写负担,降低了使用成本,提高了开发效率,但同时由于需要维护锁所属的线程以及加减计数会降低运行效率,程序的开销也会更大;

(3.3)死锁的必要条件:

① 互斥使用:一个锁被一个线程占用后,其他线程就无法占用;

② 不可抢占:一个锁被一个线程占用后,其他线程不能抢占该锁;

③ 请求与保持:当一个线程占据了多把锁之后除非显式释放锁,否则这些锁始终被该线程持有;

(以上三条都是锁本身的特点)

④ 环路等待:等待关系成环;

在实际开发中需要避免死锁,关键还是从避免环路等待入手:

针对多把锁加锁时约定好固定的顺序,就可以避免等待关系成环;

但实际情况中很少出现一个线程套锁的问题;

5.7.2 synchronized的使用

使用synchronized的本质是修改了Object对象中“对象头”内的一个标记;

【JavaEE】_1.多线程(1)_第23张图片

(1)直接修饰普通方法:此时锁对象是this:

class Counter{
    public int count=0;
    synchronized public void increase(){
        count++;
    }
}

 当两个线程同时对同一个对象进行加锁的时候才存在竞争;

(2)修饰一个代码块:需要显式指定锁对象:

class Counter{
    public int count=0;
    public void increase(){
        synchronized (this){
            count++;
        }
    }

}
public class Demo1 {
    private static Counter counter = new Counter();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
           for(int i=0;i<50000;i++){
               counter.increase();
           }
        });
        Thread t2 = new Thread(()->{
           for(int i=0;i<50000;i++){
               counter.increase();
           }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}

counter.increase()表示针对this对象进行加锁操作;

注:任何对象都可以进行加锁是java语言的特色;

(3)修饰一个静态方法:

静态方法其实就是类方法,普通方法就是实例方法;故而

synchronized修饰一个静态方法就相当于针对当前类的类对象加锁

class Counter{
    synchronized public static void func1(){
        
    }
    public static void fun2(){
        synchronized (Counter.class){
            
        }
    }
}

如在上文代码中,静态方法func1是表示为synchronized修饰的静态方法,其效果等效于静态方法func2;

注:(1)类对象就是运行程序时.class文件被加载到JVM内存中时的形态;

(2)使用synchronized很容易造成线程阻塞,一旦线程阻塞,此时放弃CPU,再次回到CPU的时间就不可控了,一旦代码中使用了synchronized,则“高性能”几乎无法实现;

5.7.3 Java标准库中的线程安全类

Java标准库中已经实现的类中有些是线程安全的,有些是线程不安全的:

线程不安全类:

①ArrayList ②LinkedList ③HashMap ④TreeMap ⑤HashSet ⑥TreeSet ⑦StringBuilder

线程安全类:

①Vector(不推荐) ②HashTable(不推荐)③ConcurrentHashMap ④StringBuffer ⑤String

注:(1)线程安全类由于一些关键方法都被synchronized修饰,保证了多线程环境下修改同一个对象不会出现线程不安全问题;

(2)String是线程安全类不是因为synchronized修饰,而是因为String是不可变对象,不存在多线程中修改造成的线程不安全问题;

同时请注意不可变对象与常量以及final没有必然联系:

不可变对象是指在该类中没有提供public的修改属性的方法,final修饰类表示类不可继承;

你可能感兴趣的:(JavaEE,java,jvm)