前言

在学习多线程之前,我们需要学习一些计算机组成的一些知识。
当去开一个虚拟机,开一个数据库,开git,开xshell,开idea, 开Nginx,开redis,启动N多个微服务。
就需要一台高性能配置的电脑(cpu8核和运行内存16G。)

为了完成特定的任务,用某种编程语言写一个软件(程序),程序要想运行就必须加载到内存中执行。
在执行程序的时候,实时的指令加载到cpu内存的指令寄存器中执行,执行过程中产生的数据要加载到数据寄存器中,ALU负责进行算术逻辑运算的操作。

系统总线System Bus):连接计算机系统的主要组件,用来降低成本和促进模块化。 可以通过软件来控制硬件。
进程: 一个正在执行中的程序就是一个进程,系统就会为这个进程发配独立的【运行资源】 进程是程序的一次执行过程,它有自己的生命周期 它会在启动程序时产生 运行程序时存在 关闭程序时消亡
比如:QQ,idea,腾讯会议,PDF 早期的计算机。
单进程:同一时间只能执行一个进程;计算器:同一时间只能执行一段代码。


随着计算机的发展,CPU的计算能力大幅提升, 按照时间线交替执行不同继承的方式。 每个执行一点点时间,感官上觉得这么多的进程是同时在运行。

进程更强调的是【内存资源分配】;线程更强调的是【计算的资源的分配】
因为有了线程的概念,一个进程的线程不能修改另一个线程的数据,线程之间是相互隔离的, 安全性更好。

理论上,一个核在一个时间点只能跑一个线程,但是cpu同一个时间能跑16线程

  1. 物理cpu内核,每颗物理CPU可以有1个或多个物理内核, 通常情况下物理CPU内核数都是固定,单核CPU就只有1个物理内核
  2. 逻辑CPU内核,操作系统可以使用逻辑CPU来模拟真实CPU。 在没有多核心CPU情况下,单核CPU只有一个核,可以把一个CPU当做多个 CPU使用,逻辑CPU的个数就是作用的CPU物理内核数。

如何创建线程

1. 继承Thread类,并且重写run方法

Thread类中的run方法不是抽象方法,Thread类也不是抽象类
MyThread当继承了Thread类之后,它就是一个独立的线程。要让线程启动。调用线程的start方法。

代码部分

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println(2);
    }
}

public class Ch01 {

    public static void main(String[] args) {
        System.out.println(1);
        MyThread myThread = new MyThread();
        myThread.start();
        System.out.println(3);
        System.out.println(4);
    }
}
当调用start方法启动一个线程时,会执行重写的run方法的代码
调用的是start(启动线程),执行的是run(使用方法)。普通的对象调方法,myThread.run();
线程有优先级,存在概率问题!做不到百分百。可能会优先主方法,然后运行mythread
也就是说,输出结果可能为1,2,3,4,也有可能是1,3,4,2等等

使用箭头函数(lambda表达式)

public class Ch03 {

    public static void main(String[] args) {
        System.out.println(1);
        new Thread(() -> System.out.println(2)).start();
        
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(3);
        System.out.println(4);
    }
}
由于启动新线程后主线程进入了休眠Thread.sleep,再执行3.sout 4.sout
输出结果一般为 1 2 3 4

2. 实现Runnable接口,并重写run方法

代码部分

class MyThread2 implements Runnable {

    @Override
    public void run() {
        System.out.println(2);
    }
}

public class Ch02 {

    public static void main(String[] args) {
        System.out.println(1);
        MyThread2 myThread2 = new MyThread2();

        Thread t = new Thread(myThread2);
        t.start();
        System.out.println(3);
        System.out.println(4);
    }
}
如果想要让线程启动,必须调用Thread类中的start方法
但是Runnable无法使用start方法,需要先创建Thread对象,然后将线程对象传进去。
输出与第一个方法相同,存在着优先级随机的可能性。

3. 实现Callable接口

代码部分

class MyThread3 implements Callable<String> {

    @Override
    public String call() throws Exception {
        System.out.println(2);
        return "call方法的返回值";
    }
}

public class Ch04 {

    public static void main(String[] args) {
        System.out.println(1);
        FutureTask<String> futureTask = new FutureTask<>(new MyThread3());
        new Thread(futureTask).start();
        System.out.println(3);
        System.out.println(4);
    }
}
一般不使用Callable,因为它需要层层调用
Callable-->FutureTask-->RunnableFuture-->Runnable-->Thread

守护线程

Java中提供两种类型的线程:

  1. 用户线程
  2. 守护程序线程
守护线程为用户线程提供服务,仅在用户线程运行时才需要。
守护线程对于后台支持任务非常有用,他可以垃圾回收。大多数JVM线程都是守护线程。QQ,主程序等就是用户线程。
任何线程继承创建它的线程守护进程状态。由于主线程是用户线程,因此在main方法内启动的任何线程默认都是守护线程。

创建守护线程

public class Ch05 extends Thread {

    @Override
    public void run() {
        super.run();
    }

    public static void main(String[] args) {
        Ch05 ch05 = new Ch05();
        ch05.setDaemon(true); // ch05就变成了守护线程
        ch05.start();
    }
}

线程的生命周期

流程图.png

当线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态。
在线程的生命周期中,它要经过新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和死亡(Dead)5 种状态
尤其是当线程启动以后,它不可能一直"霸占"着 CPU 独自运行,所以CPU 需要在多条线程之间切换,于是线程状态也会多次在运行、阻塞之间切换。
  • NEW:这个状态主要是线程未被start()调用执行
  • RUNNABLE:线程正在JVM中被执行,等待来自操作系统的调度
  • BLOCKED:阻塞。因为某些原因不能立即执行需要挂起等待。
  • WAITING:无限期等待。Object类。如果没有唤醒,则一直等待。
  • TIMED_WAITING:有限期等待,线程等待一个指定的时间
  • TERMINATED:终止线程的状态,线程已经执行完毕。
public class Ch06 {

    public static void sleep(int i) {
        try {
            // 线程休眠1秒
            Thread.sleep(i);
            System.out.println("哈哈哈...");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        sleep(3000);
    }
}
等待和阻塞两个概念有点像,阻塞因为外部原因,需要等待,
而等待一般是主动调用方法,发起主动的等待。等待还可以传入参数确定等待时间。

线程插队join

t1线程与t2线程同时启动,但是t1使用了join进行插队,此时线程t1就进入阻塞状态,直到线程t2完全执行完成,线程t1才结束阻塞状态,所以t1会先运行(也存在着不确定性)

public class Ch07 {

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    Thread.sleep(100);
                    System.out.println("这是线程1---->" + i);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    Thread.sleep(100);
                    System.out.println("这是线程2---->" + i);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

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

        try {
            // t1插队
            t1.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("----------------------");
        // 分割线出现的位置,join方法的本意阻塞主线程
    }
}

CPU多核缓存结构

物理内存:硬盘内存。(M.2固态硬盘速率目前最高,机械硬盘速率很低)
CPU缓存为了提高程序运行的性能,现在CPU在很多方面对程序进行优化。
CPU处理速度最快,内存次之,硬盘速度最低。
在CPU处理内存数据时,如果内存运行速度太慢,就会拖累CPU的速度

为了解决这样的问题,CPU设计了多级缓存策略。
CPU分为三级缓存:每个CPU都有L1,L2缓存,但是L3缓存是多核公用的。
CPU查找数据时,CPU -> l1 -> l2 -> l3 -> 内存 -> 硬盘

英特尔提出了一个协议MESI协议

  1. 修改态,此缓存被动过,内容与主内存中不同,为此缓存专有
  2. 专有态,此缓存与主内存一致,但是其他CPU中没有
  3. 共享态,此缓存与主内存一致,其他的缓存也有
  4. 无效态,此缓存无效,需要从主内存中重新读取

线程安全

指令重排

指令重排是指,代码在写的时候是有顺序的,但是实际执行可能会重新排数据

{
    int a = 1; // 1
    int b = 2; // 2
    int c = a + b; // 3
}
上面代码的情况,是有一定顺序的
执行第一行或者第二行代码,是可以不按照顺序来的,因为他们之间没有依赖关系
但是第三行代码必须依赖于第一行与第二行,一定会在他们后面执行
这就产生了指令重排,但是不会对程序的执行顺序产生干扰!

可见性

thread线程一直在高速读取缓存中的isOver,不能感知主线程已经把isOVer改成了true,这就是线程的可见性的问题。
那么该怎么解决?使用volatile定义能够强制改变变量的读写直接在内存中操作。

public class Ch03 {

    private volatile static boolean isOver = false;

    private static int number = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                while(!isOver){

                }
                System.out.println(number);
            }
        });
        thread.start();
        Thread.sleep(1000);
        number = 50;
        isOver = true;
    }
}

线程争抢

解决线程争抢的问题最好的办法就是【加锁】synchronized同步锁,线程同步.
当一个方法加上了synchronized修饰,这个方法就叫做同步方法。

public class Ch04 {

    private volatile static int count = 0;

    public synchronized static void add() {
        count ++;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                add();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("最后的结果是:" + count);
    }
}
结果输出为:20000

线程安全的实现方法

  1. 数据不可变
    一切不可变的对象一定是线程安全的。
    对象的方法的实现方法的调用者,不需要再进行任何的线程安全的保障措施。
    比如final关键字修饰的基本数据类型,字符串。
    只要一个不可变的对象被正确的创建出来,那外部的可见状态永远都不会改变。
  2. 互斥同步。加锁。【悲观锁】
  3. 非阻塞同步。【无锁编程】,自旋。我们会用cas来实现这种非阻塞同步。
  4. 无同步方案。多个线程需要共享数据,但是这些数据又可以在单独的线程中计算,得出结果
    我们可以把共享数据的可见范围限制在一个线程之内,这样就无需同步。把共享的数据拿过来,
    我用我的,你用你的,从而保证线程安全。使用ThreadLocal
public class Ch05 {

    private final static ThreadLocal<Integer> number = new ThreadLocal<>();

    private static int count = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                // t1内部自己用一个count
                number.set(count);
                for (int i = 0; i < 10; i++) {
                    number.set(count ++);
                    System.out.println("t1-----" + number.get());
                }
            }
        });
        Thread t2= new Thread(new Runnable() {
            @Override
            public void run() {
                // t2内部自己用一个count
                number.set(count);
                for (int i = 0; i < 10; i++) {
                    number.set(count ++);
                    System.out.println("t2-----" + number.get());
                }
            }
        });
        t1.start();
        t2.start();
    }
}

制作同时卖票

通过使用synchronized进行加锁
定义 private static final Object lock = new Object();
run()方法的循环中,利用sleep给予延迟
public class Ticket implements Runnable{

    private static final Object lock = new Object();

    private static Integer count = 100;

    String name;

    public Ticket(String name) {
        this.name = name;
    }

    @Override
    public void run() {
        while(Ticket.count > 0){
            try {
                Thread.sleep(300);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (Ticket.lock){
                System.out.println(name + "出票一张,还剩:" + Ticket.count-- + "张!");
            }
        }
    }

    public static void main(String[] args) {
        Thread one = new Thread(new Ticket("一号窗口"));
        Thread two = new Thread(new Ticket("二号窗口"));

        one.start();
        two.start();
    }
}

对火来说简单的事情,对于风来说却未必;
对鱼来说简单的事情,对鸟来说却未必;
对树根来说简单的事情,对树枝也未必。
—— 于尔克·舒比格 《当世界年纪还小的时候》
最后修改:2023 年 01 月 09 日
如果觉得我的文章对你有用,请随意赞赏