深入了解多线程原理

目录

     背景知识:

什么是进程?

什么是线程?

线程与进程的区别:

Thread类及常用方法:

循环打印的例子:

start() 和 run() 的区别:

通过监视窗口查看线程:

创建线程:

1.继承 Thread 类, 重写 run() 方法:

2.实现 Runnable 接口:

3.使用匿名内部类,继承 Thread 类:

4.使用匿名内部类,实现 Runnable:

5.使用 Lambda 表达式:

Thread类常见构造方法:

Thread 的几个属性:

getId():

getName():

getSate():

isAlive():

中断一个线程

等待一个线程

获取当前线程引用

休眠当前线程

线程的状态

 1.NEW:

2.RUNNABLE:

3.TERMINATED:

4.阻塞状态:

5.线程状态转换流程图:

实例验证多线程效率问题:



     背景知识:

什么是进程?

        进程是计算机操作系统对一个正在运行的程序的一种抽象,是操作系统内部进行资源分配和调度的基本单位。

        在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;

        在当代面向线程设计的计算机结构中,进程是线程的容器。

        程序是指令、数据及其组织形式的描述,进程是程序的实体。

        上面这句话换言之就是,计算机内部要管理任何现实事物,都需要将其抽象成一组有关联的数据,在java中就使用类/对象类描述这种特征。

class PCB {
    //进程的唯一标识:pid;
    //进程关联的程序信息,如哪个程序,加载到内存的区域;
    //使用的资源;
    //进度调度信息
}

如上面的例子:这样的一个 PCB 对象,就代表了一个运行着的程序,即“进程”; 操作系统再通过各种数据结构将每个 PCB 对象组织起来,方便对这些进程进行管理。

什么是线程?

       使用线程的主要目的是为了解决“并发编程”问题。每一个线程就是一个“执行流”,每个线程按照一定的顺序来执行代码,多个线程可以同时执行多份代码;

        这里举个例子就非常好理解了:我们在餐厅买饭时,档口的工作有很多种,有负责后勤备材料的、有负责烹饪的还有负责打包收费的,如果这些工作都让张三一个人来完成,那么对于他而言工作量自然很大,并且工作效率也有一定影响,那么此时张三就可以再叫来李四和王五来帮忙,这样他们分别负责意见事务,如此一来,三个人各自有不同的任务,但又密不可分,好比一个任务交给了三个执行流来执行,我们就把这种分配叫做多线程模式。同时,洗菜烹饪收费这三件事是有先后顺序的,对应到线程里也就是每个执行流要排队执行,至于为什么要排队执行,这个问题涉及到优化线程随机调度,后面将会仔细讲解。

        既然刚开始提到“多线程为了解决‘并发编程’问题”,现在我们展开分析其中道理:

        计算机的核心计算逻辑是由 CPU 来控制的,随着技术进展,CPU 的核心也在快速升级,对于并发问题,结合多进程,多核CPU可以起到一定的效果,但是并非 CPU 核心多了程序就一定能排的快了,核心再多不能让部分核心很忙,部分又在闲置,所以这还需要结合具体的程序代码,合理地将多核心运用起来才行。

        再谈到多进程吧,其实多进程模式已经可以解决并发问题,并且可以利用起来CPU多核资源了,但是多进程的缺点太明显,那就是“太重”——消耗资源多、速度较慢。为了中和这种缺陷,就产生了“多线程模式”。

        线程也可以叫做“轻量级进程”,不仅可以解决并发编程问题,还能使创建、销毁、调度等速度得到显著提升管。

线程与进程的区别:

  • 包含关系:
            进程包含线程,一个进程可以包含多个线程(不能没有),是一对多的关系,但一个线程不能同时存在于多个进程中;
  • 公用关系:
             同一个进程中的多个线程之间,公用了进程的同一份资源(即内存文件描述符表);
    内存:指在 线程1 中 new 的对象在于其处于同一进程的其他线程也能使用;
    文件描述符表:指在 线程1 中打开的文件在于其处于同一进程的其他线程也能直接使用,因为操作系统内核是利用文件描述符表来访问文件的,打开现存文件或新建文件时,内核会返回一个文件描述符。读写文件也需要使用文件描述符来指定待读写的文件;
  • 随机调度:
             一个核心上执行一个线程,如果你的一个进程中有两个线程,那么操作系统在调度的时候,那个线程在哪个核心上执行都是有可能的;
  • PCB的组织:
             线程也是通过PCB来描述的,一个线程可能对应一个PCB或者多个,PCB中得到的“状态、上下文、优先级、记账信息等”都是每个线程各自的记录,但是在同一个进程中的PCB之间,“pid、内存指针、文件描述符表”是一样的。
  • 基本单位:
             不要拘泥于教材,站在“轻量级进程”角度而言,操作系统和CPU在实际调度的时候,是以线程为基本单位进行调度的,每个线程也都有自己的执行逻辑;实际中只要涉及到“调度”就基本上和进程没关系了,可以理解为,进程专门负责资源分配,线程主要来接管和调度相关的一切内容;

 下面这个生动的例子可以帮助我们更清晰的区别多进程与多线程:

由此图不难提出疑问:既然线程解决了两面性的问题,那线程一定就能更快嘛?

就上面吃蛋糕而言,许多人在一起吃,相当于多个线程来处理同一个任务,就会出现三种情况:

1.桌子大小有限,不够所有人坐(对应CPU核心有限,不能将所有线程调度起来);

2.人太多,有的人正在吃,有的人吃完了又要去拿,推推搡搡导致正在吃的人不能专心吃(对应有的线程正在处理中,其他线程又要对其产生各种干扰);

3.线程太多,在线程调度上开销较高

4.两个人看上同一块蛋糕(对应线程安全问题);

5.张三把李四的蛋糕抢走了,李四直接生气干扰大家都吃不了了(对应一个线程抛异常,如果处理不好,很可能导致整个进程的崩溃);

看来多线程模式也存在诸多问题,那么下面就要对所有问题一一展开讲解。


Thread类及常用方法:

        每个执行流都需要一个对象来描述,而Thread对象就是用来描述执行流的,此时JVM再将这些Thread对象组织起来,作一系列的调度与管理。

        Thread类是在java.lang下的,所以用的时候不需要导包。

循环打印的例子:

        先写一个交替打印的例子来直观感受一下吧:

        首先定义一个类 MyThread 继承 Thread 类,内部方法打印“hello thrad”,接着 main 方法内部实例出一个 t 对象,这个对象就是用来描述线程的,即称为 t 线程,调用 t.start() 方法启动 t 线程,同时 main 方法内部也要打印“hello main”,如此一来,main 方法和MyThread类内部的 run 方法都要打印不同的内容,那么就会因为线程的随机调度和抢占式执行导致“hello Thread” 和 “hello main”形成交替打印。至于先打印哪个,顺序是不一定的,取决于操作系统内部具体调度策略。

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

        while (true) {
            System.out.println("hello main");
        }
    }
}

 深入理解:

        从微观角度来看:main() 方法是一个主线程,start() 方法在这里创建了一个新的线程,新的线程负责执行 t.run() 方法,也就是调度操作系统的API,通过操作系统内部创建新的PCB,并且把要执行的指令交给这个PCB,当PCB被调度到CPU上执行时,也就可以执行到线程中 run() 方法了;

        从宏观角度来看:如果直接在main()方法内部写一个打印,想要实现交替不是非常容易实现,那么此时程序的进程中就只有一个线程main;如果主线程调用 t,start() ,创建出一个新的线程,新的线程再调用 t.run(),如果run()方法执行完毕,这个新的线程也就自然销毁了。

start() 和 run() 的区别:

        执行 start() 就是真正创建一个线程;run() 只是描述了线程要干的活,如果没有创建新的线程,而是直接在 main() 内部调用 run(),那就是 main() 把所有的活都干了。

通过监视窗口查看线程:

在 JDK 中自带了一个可以监视线程的窗口:jconsole

可以直接搜索,也可以在自己电脑JDK的文件夹内找: 

 选择连接刚才运行的程序,接着同意不安全连接

学会了使用 jconsole 的使用,就能在后续调试过程中方便找出 bug 所在地。


创建线程:

创建线程有很多种写法:

1.继承 Thread 类, 重写 run() 方法:

class MyThreads extends Thread {
    @Override
    public void run() {
        while (true) {
            System.out.println("my thread");
        }
    }
}

2.实现 Runnable 接口:

        Runnable 的作用是描述一个要执行的任务,借助了“解耦合”的思想,将 线程 和 线程要干的活 之间分开,这样写的好处在于,如果某一天不需要使用多线程,而是用多进程,或者说线程池、协程时,此时代码改动就会小一些,错误的概率的可以稍作降低。

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("hello myRunnable");
    }
}
public class ThreadDemo03 {
    public static void main(String[] args) {
        //用 runnable 来描述一个任务
        Runnable runnable = new MyRunnable();
        //将任务交给 t 线程来执行
        Thread t = new Thread(runnable);
        t.start();
    }
}

3.使用匿名内部类,继承 Thread 类:

public class ThreadDemo04 {
    public static void main(String[] args) {
        Thread t = new MyThread() {
            @Override
            public void run() {
                System.out.println("匿名内部类");
            }
        };
        t.start();
    }
}

4.使用匿名内部类,实现 Runnable:

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

5.使用 Lambda 表达式:

         把任务用 lambda 表达式来描述,直接把 Lambda 传给 Thread 构造方法;

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


Thread类常见构造方法:

Thread() 创建线程对象
Thread(Runnable target) 使用Runnable描述的“任务”创建对象
Thread(String name) 创建线程对象,并命名
Thread(Runnable target,String name) 使用Runnable描述的“任务”创建对象,并命名
Thread(ThreadGroup group,Runnable target) 分组管理线程

其实给线程起名字,调试时便于观察;

举个例子:

public class ThreadDemo07 {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            while (true) {
                System.out.println("run");
            }
        }, "myThread");

        t.start();
    }
}


Thread 的几个属性:

属性 获取方法 解释
ID getId() ID是线程的唯一标识,不同的线程不会重复
名称 getName() 构造方法里的名字
状态 getState() 当前线程所处的状态,下面将仔细介绍
优先级 getPriority() 优先级高的线程理论上来说更容易被调度到,这个可以获取也可以设置,但一般设置了没有用
是否后台线程 isDeamon() 后台线程(也叫守护线程)JVM会在一个进程的所有非后台线程结束后,才会结束运行。前台线程不结束,进程就走不完;后台线程没做完,进程是可以结束的,也可以使用setDeamon手动设置为后台线程
是否存活 isAlive() 简单的理解为 run 方法是否运行结束了。start之前为false;start之后为true
是否被中断 isInterrupt() 下面将大篇幅讲解

getId():

public class ThreadDemo07 {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            System.out.println("run");
        }, "myThread");

        t.start();

        System.out.println(t.getId());;
    }
}

getName():

public class ThreadDemo07 {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            System.out.println("run");
        }, "myThread");

        t.start();

        System.out.println(t.getName());;
    }
}

getSate():

public class ThreadDemo07 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            System.out.println("run");
        }, "myThread");

        System.out.println(t.getState());

        t.start();

        System.out.println(t.getState());
        Thread.sleep(5000);

        System.out.println(t.getState());

    }
}

线程的这三种状态将在下面的内容中大篇幅介绍.

isAlive():

public class ThreadDemo07 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                try {
                    Thread.sleep(1000); //保证线程至少执行 5s - 便于观察
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("running");
            }
        }, "myThread");
        //①观察 start 之前是否存活
        System.out.println(t.isAlive());
        t.start();
        //②观察 start 之后线程执行过程中是否存活
        System.out.println(t.isAlive());

        //③至少等待 8s,观察线程 t线程执行完后是否存活
        Thread.sleep(8000);
        System.out.println(t.isAlive());

    }
}

此处再强调一遍:只有 start 之后才创建真正的线程,start 之后才真正开始做任务,调用 start,就会让内核创建一个 PCB,此时这个 PCB 才表示一个真正的线程。isAlive是在判断当前系统里这个线程是不是真的存在了;

  • 调用 start 之前,isAlive为 false;
  • 调用 start 之后,isAlive为 true;

        如果内核里的线程将 run 里面的活干完了,此时线程就会销毁,PCB也随之消失,但是Thread 创建出来的这个 t 对象还不一定被释放,此时的 isAlive 也是false;

  • 如果 t 的 run 还没启动,isAlive 就是 false;
  • 如果 t 的 run 启动中,isAlive 就是 true;
  • 如果 t 的 run 执行完毕,isAlive 就是 false;

上面这一部分,也就可以称之为“启动一个线程”;


中断一个线程

首先列出中断线程相关的方法:

方法 说明
public void interrupt() 中断对象关联的线程,如果线程正在阻塞,则以异常方式通知,否则设置标志位
public static boolean interrupted() 判断当前线程的中断标志位是否设置,调用后清除标志位
public boolean isInterrupted() 判断对象关联的线程的标志位是否设置,调用后不清除标志位

代码示例:

public class ThreadDemo09 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                System.out.println("running");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
        Thread.sleep(3000);

        t.interrupt();
    }
}

执行结果: 

 从结果来看,我们会疑惑:既然我都让线程终止了,为什么它报异常后又执行下去了呢?

        我们一步步来看:

        其实,该程序在执行时,sleep遇到异常后虽然配合进程终止了,但随后又清除了标志位,将原来的 false 改为了 true。(同样,wait / join 在线程阻塞挂起时也会有这样的效果);

        因此,我们这里就要强调:中断线程的含义,并非真正地让进程立即停止,而只是一个“通知停止”的效果,至于线程是否真正停止,何时停止,取决于线程内代码的具体实现;

        为了解决这一问题,将上述代码修改一下再来看:

①第一种: 将catch的语句修改成结束语句即可

或者:

②第二种:让它遇到异常后稍微等待一会儿再结束

         还有其他各种处理方法,具体为什么要这么设计?其实就是为了唤醒之后,线程到底是否要终止?何时终止?选择权交给了程序员自己。

        以上紫色字体内容甚为重要!!!


等待一个线程

        相关方法:

方法 说明
public void join() 等待某个线程结束,“死等”
public void join(long millis) 等待某个线程结束,最多等millis毫秒
public void join(long millis,int nanos) 精度更高

 代码实例:

public class ThreadDemo10 {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            for (int i = 0; i < 3; i++) {
                System.out.println("running");
            }
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        t.start();
        System.out.println("join之前");

        try {
            t.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("join之后");

    }
}

        原本执行完 start 后,t 线程和 main 线程分头行动,并发执行,但是 t.join() 就起到了等待作用,使 main 线程等待 t 线程执行结束再继续执行自己,在这段代码中也就体现在打印了三次“running”之后,t 线程阻塞三秒,之后 main 继续执行。


获取当前线程引用

        这个方法在中断线程中使用过.这是一个静态方法

方法 说明
public static Thread currentThread() 返回当前线程对象的引用


休眠当前线程

相关方法:

方法 说明
public static void sleep(long millis) throws InterruptedException 休眠当前线程 millis 毫秒
public static void sleep(long millis,int nanos) throws InteruptedException 更高精度的休眠

        休眠线程本质上就是让这个线程不参与调度了,可以理解为,有两个队列,一个是执行队列,另一个是阻塞队列(后面介绍),被sleep的线程就会从执行队列进入到阻塞队列中(这个状态也叫作“hang”)。

        一旦线程进入阻塞状态,对应PCB也就进入阻塞队列了,此时就暂时无法参与调度,比如调用 sleep(1000),对应的线程PCB就要在阻塞队列中等待 1000ms ,试想:当这个 PCB 回到了就绪队列,会立即被调度吗?其实虽然是 sleep(1000),但考虑到还有地调度的开销,实际时间就要大于 1000ms.


线程的状态

        通过下面这段代码我们可以观察到线程的所有状态:

public class ThreadDemo11 {
    public static void main(String[] args) {
        for (Thread.State state : Thread.State.values()) {
            System.out.println(state);
        }
    }
}

进入Thread.State内部就可以看到六种状态:

 1.NEW:

创建了 Thread 对象,但是还没有调用 start ,内核还没有创建对应PCB;

2.RUNNABLE:

可运行的,它有两种情况:正在CPU上执行的,或者在就绪队列中,随时可以去CPU上执行的;

3.TERMINATED:

表示内核中的PCB已经执行完毕,但是 Thead 对象依然在;

4.阻塞状态:

WAITIING、TIMED_WAITING、BLOCKED都是线程PCB在阻塞队列中,下面会有介绍;

5.线程状态转换流程图:

 通过运行代码来观察:

public class ThreadDemo12 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {});

        System.out.println("start之前:" + t.getState());

        t.start();
        System.out.println("执行中:" + t.getState());
        t.join();

        System.out.println("执行后:" + t.getState());

    }
}


实例验证多线程效率问题:

CPU密集问题:假设有两个变量,需要把这两个变量都自增1000亿次。

两种处理方法:

第一种,串行执行,使用一个线程,先对a自增,再对b自增:

public class ThreadDemo13 {
    public static void main(String[] args) {
        serial();
    }
    public static void serial() {
        //获取时间
        long beg = System.currentTimeMillis();

        long a = 0;
        for (long i = 0; i < 100_0000_0000L; i++) {
            a++;
        }
        long b = 0;
        for (long i = 0; i < 100_0000_0000L; i++) {
            b++;
        }

        long end = System.currentTimeMillis();
        System.out.println("执行时间:" + (end - beg) + " ms");
    }
}

 第二种,并发执行,两个线程同时分别对a和b自增:

public class ThreadDemo14 {
    public static void main(String[] args) {
        concurrency();
    }
    public static void concurrency() {
        Thread t1 = new Thread(() -> {
            long a = 0;
            for (long i = 0; i < 100_0000_0000L; i++) {
                a++;
            }
        });
        Thread t2 = new Thread(() -> {
            long b = 0;
            for (long i = 0; i < 100_0000_0000L; i++) {
                b++;
            }
        });

        long beg = System.currentTimeMillis();

        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        long end = System.currentTimeMillis();
        System.out.println("并发执行时间:" + (end - beg) + " ms");
    }

}

        可见,多线程在这种 CPU 密集型的任务中,有非常大的作用,可以充分利用 CPU 的多核资源,从而加快程序的运行效率;

        除此之外还需要注意,并非使用多线程就一定能提高效率:一方面要看是否多核、另一方面还要满足核心空闲才行。


        这一篇幅重点介绍了多线程的基础知识,后续文章讲逐一展开多线程实际终点问题“线程安全”“Synchronized锁的使用”“单例模式”“锁策略”“接口、原子类”等面试常考知识点


猜你喜欢

转载自blog.csdn.net/m0_65190367/article/details/128412977