进程与多线程(一)

1.操作系统

1.1操作系统的定位

操作系统本质上是一个用来搞管理的软件:

  1. 对下管理所有的硬件设备
  2. 对上要给软件提供稳定的的运行环境

image.png
一个操作系统 = 内核 + 配套的应用程序
操作系统内核是操作系统中最核心的功能,硬件的驱动程序都是在系统内核中执行的。内核需要给很多的应用程序提供支持。
image.png

1.2什么是进程/任务(Process/Task)

进程/任务就是指一个已经跑起来的程序。

image.png
这些都是正在执行的进程。(有的是系统自动创建的,有的是程序员手动创建的)
image.png
每一个进程想要执行,就需要消耗一定的系统资源(硬件资源)。
每一个进程,都是系统资源分配的基本单位
那么,进程在系统中是如何管理的呢?

主要有两个角度:
1.描述 :使用类/结构体,把被管理的一个对象的各个属性都表示出来。
2.管理 :使用数据结构,把这些表示出来的对象都给串起来。(方便后续的增删改查)

系统中专门有一个结构体(操作系统内核是用C/C++实现的)描述进程的属性,这个结构体统称为“进程控制块”(PCB)。一个进程可以使用一个或者多个PCB表示。
系统中使用类似_双向链表_这样的数据结构来组织多个PCB。创建新的进程,就是创建新的PCB,并把这些PCB插入到双向链表中;销毁进程,就是把这些PCB从链表上删除并释放;展示进程列表,就是遍历链表的每个节点。
这里,我们先讨论一下PCB里面的属性。

  1. pid :进程的身份标识。

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

  1. 内存指针(一组属性)----描述了进程持有的内存资源是啥样的

每个进程在运行的时候都会分配一定的内存空间,这个进程的内存空间,具体在哪里,分配的内存空间具体有哪些部分,每个部分都负责干啥,需要这么一组指针来区分。
最典型的,进程的内存空间,需要有专门的区域存储要执行的指令(CPU上能够执行任务的最小单元),以及指令依赖的数据,同时还要存储一些运行时产生的临时数据。
比如,C语言的程序,写一些代码/函数,这些程序最终会被编译生成exe(包含一些二进制指令),双击exe,系统就会读取可执行文件的内容,并加载到内存中(将这些二进制指令从硬盘加载到内存中),CPU才能从内存中取走指令,进一步执行指令,exe中的数据也需要加载到内存中,同时程序运行过程中也会产生一些临时数据,这些临时数据需要专门的空间来存储。

  1. 文件描述符表----描述了进程持有的硬盘资源是啥样的
    和文件有关 => 和硬盘有关

一个进程也需要涉及到硬盘操作,就需要按照文件的方式来操作。当前进程关联了哪些文件,都能操作哪些文件,都是通过文件描述符表。

进程的CPU资源如何体现?这就涉及到进程的调度

一个进程要执行,就需要CPU来执行这上面的指令。早期的电脑是单核CPU,一个CPU核心,同一时刻,只能执行一个进程的指令。

在这个背景下,如何实现多任务同时执行?

分时复用:只要这些进程轮转的速度足够快,看起来就好像多个进程同时执行一样。

实际上,现代的CPU都是多核心的。

如果两个进程同时在两个CPU核心上执行,微观上也是同时执行,此时称为并行。一个CPU核心上,通过快速轮转调度的方式,执行多个进程,宏观上是同时执行,微观上有先有后,这种情况称为并发。
并行和并发,在应用程序这一层感知不到,都是系统内部完成调度的。

PCB中引入一些属性,用来支持操作系统实现 进程调度

  1. 进程的状态
    进程时刻准备好去CPU上执行,这种情况就称为“就绪状态”。
    就绪状态有两种情况:
    1)进程正在CPU上执行
    2)进程虽然没在CPU上执行,但是时刻准备去CPU上执行
    某个进程,某种执行条件不具备(比如,进程等待用户输入),导致进程无法参与CPU的调度执行,这种状态称为“阻塞状态”。
  2. 进程的优先级
    操作系统在调度多个进程的时候,并非一视同仁,有些进程会给更高的优先级,优先调度。
  3. 进程的上下文
    进程从cpu离开之前,需要保存现场,把当前cpu中各种寄存器的状态,都记录到内存中,等到下次进程回到CPU上执行的时候,此时就可以把保存的这些寄存器的值恢复回去,进程就会沿着上次执行的位置继续往后执行(存档,读档)
  4. 进程的记账信息
    记录当前进程持有的CPU情况(在CPU上执行多久了),就可以作为操作系统调度进程的参考依据。

虚拟地址空间
早期的操作系统,程序运行时分配的内存就是物理内存。
image.png
如果代码写出bug,内存访问越界了,B进程越界访问了A进程的内存,把A的内存写坏了(写成错误的值),A进程可能就崩溃了。
而操作系统是要给进程提供稳定的运行环境的,因此,操作系统引入“虚拟地址空间”,不是直接分配物理内存,而是分配虚拟的内存空间。操作系统对于内存又进行了一层抽象。
image.png
A操作某个内存中的数据,就需要把操作的虚拟地址告诉系统,系统再把虚拟地址翻译成物理地址(有一个类似于hash表这样的映射结构,称为页表)再操作物理地址。在翻译的时候,操作系统会进行检查和校验,看当前这个虚拟地址是否可以顺利完成翻译,如果给定的虚拟地址是非法的,是一个越界访问,系统就能及时发现,并对当前的进程进行处理,不会波及到其他进程(不会真的修改物理内存)。

2.多线程

2.1为什么引入线程

引入多个进程的初心是为了实现并发编程(多核CPU时代)。
多进程,实现并发编程,效果也是非常理想的。但是,多进程编程模型也有明显的缺点,进程太重量,开销比较大,效率不高。(即:创建一个进程,消耗的时间比较多,销毁一个进程,消耗的时间也比较多,调度一个进程消耗的时间也比较多)。时间消耗在申请资源上(进程是资源分配的基本单位)。如果需要频繁的创建/销毁进程,这个时候开销就不能忽视了。
为了解决上述问题,就引入了线程(Thread)
线程也叫“轻量级进程”,创建线程比创建进程更快,销毁线程比销毁进程更快,调度线程比调度进程更快。

线程不能独立存在,而要依附于进程,即进程包含线程,进程可以包含一个线程,也可以包含多个线程。
一个进程在最开始的时候,至少有一个线程,这个进程负责完成执行代码的工作,也可以根据需要创建出更多的线程,每个线程都能独立的执行一些代码,从而实现“并发编程”的效果。

前面谈到的进程调度都是基于“一个进程里面只有一个线程”的情况。实际上,一个进程中是可以有多个线程的,每个线程都能够独立的进行调度。

一个进程,可能使用一个PCB表示,也可以使用多个PCB表示,每个PCB都对应一个线程,每个线程也有状态、优先级、上下文、记账信息等。
除此之外,前面谈到的pid也是相同的,内存指针、文件描述符表也是共用同一份的。

上述结构,决定了线程的特点:

  1. 每个线程都可以独立的去CPU上调度执行
  2. 同一个进程的多个线程之间,共用同一份内存空间和文件资源… (创建线程的时候,不需要重新申请资源了,直接复用之前已经分配给进程的资源,省去了资源分配的开销,于是创建效率就提高了)

总结:

  • 进程中包含线程=> 一个进程可以由多个PCB共同表示=> 每个PCB用来表示一个线程=>
    每个进程都有自己的状态、上下文、优先级、记账信息=> 每个线程都可以独立的去CPU上调度执行=>
    这些PCB共用了同样的内存指针和文件描述符表=> 创建线程(PCB)不需要重新申请资源=> 创建/销毁效率都提高了

    线程是调度执行的基本单位。
    一个系统中,可以有很多进程,每个进程都有自己的资源;一个进程中,可以有很多线程,每个线程都可以独立调度,共享内存/硬盘资源。

2.2线程和进程的区别

  1. 进程包含线程。一个进程里面可以有一个线程,也可以有多个线程
  2. 进程和线程都是用来实现_并发编程_场景的,但是线程比进程更轻量、更高效
  3. 同一个进程的线程之间,共用同一份内存资源和硬盘资源,省去了申请资源的开销
  4. 进程和进程之间是具有独立性的,一个进程挂了,不会影响到其他进程;同一个进程的线程之间是可能会相会影响的(线程安全问题+线程出现异常)
  5. 进程是资源分配的基本单位,线程是调度执行的基本单位

2.3Java的线程和操作系统线程的关系

线程是操作系统的概念,操作系统提供了一些API供用户使用,Java针对上述系统API进行了封装(为了实现跨平台),Java 标准库中 Thread 类可以视为是对操作系统提供的 API 进行了进一步的抽象和封装。

2.4创建线程

方法一:继承Thread,重写run

  • 自定义MyThread类,继承自Thread
  • 在MyThread中重写run方法
  • 在主线程中创建MyThread类的对象
  • 启动线程
class MyThread extends Thread {
    @Override
    public void run() {
        while (true) {
            System.out.println("hello thread");
        }
    }
}
public class demo1 {
    public static void main(String[] args) {
        Thread t = new MyThread();
        t.start();
        while (true) {
            System.out.println("hello main");
        }
    }
}

run 和 start 都是Thread的成员,run方法是线程的入口方法,描述了线程要执行的任务,start方法则是真正的调用系统API,在系统中创建了线程,再让线程调用run方法。
可以看到,这两个while循环再“同时执行”,两边的日志在交替打印,
每个线程都是一个独立的执行流。
image.png
image.png
我们可以看到在执行到t.start()之后,开始兵分两路,一路继续执行主线程的任务,一路执行t线程的run方法,这就是并发执行,实现了并发编程。
如果把t.start()改成t.run(),会出现怎样的情况呢?
image.png
image.png
可以看到,此时运行结果里只有hello thread,改成run之后,代码中不会创建出新的线程,只有一个主线程,主线程里面只能依次执行循环,一个循环结束才能执行下一个循环。
当前这两个线程的while循环转的太快,我们希望转的慢点,就可以在循环体里加入sleep,sleep是Thread的静态方法。
在调用sleep方法时可能会抛出异常,这个异常是受查异常

class MyThread extends Thread {
    @Override
    public void run() {
        while (true) {
            System.out.println("hello thread");
            // 此处必须try catch,不能throws,这个代码是重写父类的run
            // 父类没有throws,子类就不能throws
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}
public class demo1 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new MyThread();
        t.start();
        //t.run();
        while (true) {
            System.out.println("hello main");
            Thread.sleep(1000);
        }
    }
}

image.png
可以看到打印出来的内容,顺序是不确定的,这两个线程都是休眠1000ms,时间到了之后,这两个线程谁先执行是“随机”的,操作系统对于线程的调度是不确定的,“随机”的。
通过上面这个例子,可以发现:每个线程都是一个独立的执行流,线程和线程之间是并发执行的,多个线程间调度的顺序是随机的。

方法二:实现Runnable接口,重写run

class MyRunnable implements Runnable {
    // 这里还是描述了线程的任务是啥样的
    @Override
    public void run() {
        while (true) {
            System.out.println("hello thread");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}
public class demo1 {
    public static void main(String[] args) throws InterruptedException {
        Runnable runnable = new MyRunnable();
        // Runnable 表示一个“可以执行的任务”,把这个任务交给谁来完成,Runnable本身并不关心
        // 把这个任务交给线程来执行
        Thread t = new Thread(runnable);
        // 启动线程,调用系统API,创建线程
        t.start();

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

image.png
使用Runnable的写法和直接使用Thread的区别是:解耦合(耦合就是指两个模块之间相互影响,相互作用的程度,相互影响的程度越高,说明耦合程度越高)。
创建一个线程,需要进行两个关键操作:

  1. 明确线程要执行的任务
  2. 调用系统API,创建出线程

这个任务只是单纯执行一段代码,任务本身不一定和 线程 是强相关的,这个任务使用单线程执行,还是使用多线程执行,还是使用其他方式(线程池/协程…)都没啥区别,所以就可以把任务本身提取出来,此时就可以随时把代码改成使用其他方式来执行这个任务。

方法三:继承Thread,重写run,使用匿名内部类

image.png
先创建出新的类,这个类叫啥名字,不知道,只知道这个类是Thread的子类。同时把这个类的实例给创建出来,不知道这个类的名字不影响,因为这个类本身只使用一次。然后重写父类的run方法。

public class demo2 {
    public static void main(String[] args) throws InterruptedException {
        // 使用匿名内部类
        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");
            Thread.sleep(1000);
        }
    }

}

image.png

方法四:实现Runnable接口,重写run,使用匿名内部类

public class demo3 {
    public static void main(String[] args) throws InterruptedException {
        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");
            Thread.sleep(1000);
        }
    }
}

image.png

方法五:基于Lambda表达式

public class demo4 {
    public static void main(String[] args) throws InterruptedException {
        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");
            Thread.sleep(1000);
        }
    }

}

image.png

2.5Thread类的常见方法

Thread的常见构造方法

image.png
创建线程的时候可以指定名字,name不影响线程的执行,后续调试的时候方便区分。

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();
    }
}

image.png

Thread的几个常见属性

属性获取方法
IDgetId()
名称getName()
状态getSate()
优先级getPriority()
是否是后台线程/守护线程isDaemon()
是否存活isAlive()
是否被中断isInterrupter()
  1. 后台线程(守护线程):后台线程不结束不影响整个进程的结束。
    前台线程:一个java进程中,如果前台线程没有结束,此时整个进程不会结束。
    默认情况下:一个线程是前台线程。
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.setDaemon(true);
        
        t.start();
    }
}

image.png
可以看到,运行结果里面什么也没有。
改成后台线程之后,主线程飞快执行完了,此时没有其他前台线程,于是进程结束,t线程还来不及执行,就完了。

Thread对象的生命周期要比系统内核中线程的生命周期更长一些,会存在Thread对象还在,内核中线程已经销毁了这样的情况,使用isAlive判定内核线程是不是已经没了。

启动一个线程–start()方法

start()方法内部会调用系统API,在系统内核中创建出一个线程。
run()方法,只是单纯描述了该线程具体要执行什么任务(会在start创建好线程之后,自动被调用)。

终止/打断一个线程

终止一个线程就是让这个线程停止运行(销毁线程),在Java中,要销毁/终止一个线程的做法比较唯一,就是想办法让run方法尽快执行完。
方法一:设置标志位

public class demo6 {
    // 终止线程----设置标志位
    private static boolean isQuit = false;// isQuit是成员变量,后面main方法中是内部类访问外部类属性
    public static void main(String[] args) throws InterruptedException {
        //boolean isQuit = false;// 变量捕获,只能捕获final或者实际上是final的变量

        Thread t = new Thread(() -> {
           while (!isQuit) {
               // 此处打印可以替换为任意逻辑来表示线程的实际工作内容
               System.out.println("线程工作中");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }
           }
            System.out.println("线程工作结束");
        });

        t.start();
        Thread.sleep(5000);

        // 设置标志位为true
        isQuit = true;
        System.out.println("设置isQuit为true");
    }
}

image.png

上述方案不够优雅:

  1. 需要手动创建变量
  2. 当线程内部再sleep的时候,主线程修改变量,新线程内部不能及时响应

方法二:
image.png
Thread.currentThread()是获取当前线程的实例,此处就是t,哪个线程调用currrentThread()方法,就返回哪个线程的对象。此处不能写成t.isInterrupted(),因为t线程还没构造好。
image.png就是Thread内部的标志位,这个标志位可以用来判断线程是否结束。

public class demo7 {
    public static void main(String[] args) {
        Thread t =new Thread(() -> {
           // Thread 内部有一个现成的标志位
            while (!Thread.currentThread().isInterrupted()) {
                System.out.println("线程工作中");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("线程工作结束");
        });
        t.start();
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        System.out.println("让t线程终止");
        t.interrupt();
    }
}

image.png这个操作就是把上述Thread对象内部的标志位设为true.而且即使线程内部的逻辑出现阻塞(sleep)了,也可以使用这个方法唤醒。
image.png
正常来说,sleep会在休眠时间到了之后,才会被唤醒,但是此处的interrupt方法会使sleep内部触发一个异常,从而提前被唤醒。
但是会发现运行结果是这样的:
image.png
我们发现异常确实出现了,sleep也被唤醒了,但是t线程并没有结束。
主要原因是:interrupt唤醒线程之后,此时,sleep方法抛出异常之后,会自动清除标志位,这样就使得“设置标志位”的效果好像没有生效一样。
本来image.png这个标志位是false,调用image.png这个方法之后,会把image.png这个标志位设为true,同时使sleep抛出异常;结果sleep抛出异常之后,又会把上面这个标志位清除掉,这样就使得设置标志位的工作没有生效一样。
为社么要这么设定呢?

Java是期望当线程收到“要中断”这样的信号的时候,可以由程序员自主决定接下来要怎么处理。

  • 第一种是:

image.png
执行结果就是上述情况。

  • 第二种是:加上一个break,表示线程立即结束

image.png
执行结果:
image.png

  • 第三种是:做一些其他工作,完成之后,再break

image.png
执行结果:
image.png
这样,就让程序员有了更多可操作空间,但是,这些可操作空间的前提是通过“异常”的方式唤醒的。如果没有sleep,此时就不会触发异常,也就不会有上述的可操作空间,此时的目的就非常明确。

等待线程–join()

public class demo8 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
           for (int i = 0; i < 5; i++) {
               System.out.println("线程工作中");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }
           }
        });
        t.start();

        // 此时主线程等待t线程结束
        // 一旦调用join,主线程就会触发阻塞,此时t线程就可以趁机完成后续操作
        // 一直阻塞到t线程执行完毕,join才会解除阻塞,主线程开始执行
        System.out.println("join 之前");
        t.join();
        System.out.println("join 之后");
    }
}

image.png

t.join()的工作过程:

  1. 如果t线程正在运行中,此时调用join的线程就会发生阻塞,直到t线程执行结束,阻塞终止(哪个线程调用join,哪个线程阻塞)
  2. 如果t线程已经执行完了,此时调用join的线程就直接返回了,不会阻塞。

上面这种是默认”死等“,除了这种还有一个带有”超时时间“的版本
image.png

2.6线程的状态

前面我们已经讨论过进程最核心的状态,一个是就绪状态,一个是阻塞状态(对于线程同样适用,因为是以线程为单位进行调度的)。在Java中,又赋予了线程一些其他状态。

  • NEW:Thread对象已经有了,start方法还没调用,还没在内核中创建一个线程。
  • TERMINATED:Thread对象还在,内核中的线程已经没了。
  • RUNNABLE:就绪状态(线程已经在CPU上执行了,或者线程正在排队等待上CPU上执行
  • TIMED_WAITING:阻塞,由于sleep这种固定时间的方式产生的阻塞。
  • WAITING:阻塞,由于wait这种不固定时间产生的阻塞。
  • BLOCKED:阻塞,由于锁竞争产生的阻塞。
public static void main(String[] args) {
        Thread t = new Thread(() -> {

        });
        // 在调用start之前获取线程状态,此时就是NEW状态
        System.out.println(t.getState());
    }

image.png

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

        });
        // 在调用start之前获取线程状态,此时就是NEW状态
        System.out.println(t.getState());
        t.start();

        t.join();
        // 在线程执行结束之后,获取线程状态,此时就是TERMINATED状态
        System.out.println(t.getState());

    }

image.png

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

        });
        // 在调用start之前获取线程状态,此时就是NEW状态
        System.out.println(t.getState());
        t.start();

        for (int i = 0; i < 5; i++) {
            System.out.println(t.getState());
        }
    }

此时t线程正在快速运行
image.png

public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (true) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

            }

        });
        // 在调用start之前获取线程状态,此时就是NEW状态
        System.out.println(t.getState());
        t.start();

        for (int i = 0; i < 5; i++) {
            System.out.println(t.getState());
            Thread.sleep(1000);
        }
    }

t线程正在执行sleep,就是TIMED_WAITING状态。
image.png

2.7线程安全问题【重点】

有些代码在单线程环境下执行,完全正确,但是如果同样的代码,让多个线程同时执行,此时可能会出现bug,这种就是“线程安全问题”。

public class demo10 {
    // 定义一个Int类型的变量
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            // 让count自增5w次
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(() -> {
            // 让count自增5w万次
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });

        t1.start();
        t2.start();
        // 这里如果没有这两个join,肯定不行,线程还没自增完,主线程就开始打印了,很有可能结果是0
        t1.join();
        t2.join();
        // 预期count是10W
        System.out.println("count:" + count);
    }
}

运行多次以后,我们会发现,上述代码的运行结果是一个“随机值”,并不是我们期望的100000.
image.pngimage.pngimage.png
很明显,上述问题就是一个典型的“线程安全问题”。
image.png
我们发现,如果把t1.join的顺序换一下,执行结果就是image.png。意味着当t1线程正在运行的过程中,t2是不会启动的,虽然上述代码是写在两个线程中,但是并不是“同时”执行的,所以运行结果就是我们期望的100000。
站在cpu的角度,count++这个操作,在cpu上是通过三个指令来实现的:

  1. load 把数据从内存读到寄存器上
  2. add 把寄存器中的数据+1
  3. save 再把寄存器中的数据,保存在内存中

如果是多线程执行上述代码,由于线程间的调度是随机的,在有些调度顺序下,线程的逻辑就会出现问题:
image.png
image.png
此处这两个线程执行count++,中间会产生无数种结果,因为可能还会存在t1执行一次count++的时候,t2可能执行了多次。
综上:我们会发现,在多线程程序中,最困难的一点就是,线程的随机调度使得两个线程执行逻辑的先后顺序存在很多种可能,我们必须要保证这些所有可能的情况下,代码的执行结果都是正确的。
在上述排序中有的执行结果是正确的,有的执行结果是错误的。
image.png
这种情况下,两个线程分别自增1,最终结果是2,符合预期。
image.png
image.png
但是这种情况下,两个线程分别自增一次,预期结果应该是2,实际上只有1,这就相当于在自增过程中,两个线程的结果没有往上累加,而是独立运行的。
image.png
综上,产生线程安全的原因有:

  1. 操作系统中,线程的调度顺序是随机的(抢占式执行)(罪魁祸首)
  2. 当前是,两个线程针对同一个变量进行修改
    如果是:一个线程针对一个变量进行修改,OK的
    两个线程针对不同变量修改,OK的
    两个线程针对一个变量读取,OK的
  3. 修改操作不是原子的
    此处给定的count++就属于非原子操作(先读,再修改),如果一段逻辑中需要根据一定的条件来决定是否进行修改,也是存在类似的问题。
    假设count++操作是原子的(比如有一个cpu指令可以一次完成上述三个操作),上述问题就不会出现了。
  4. 内存可见性问题
    上述代码还不涉及这个问题
  5. 指令重排序问题
    上述代码还不涉及这个问题

为了解决线程安全问题,我们就需要从上述的5个原因入手。

第一个原因,是操作系统内核就是这样实现的,我们改不了
第二个原因,有时我们可以通过调整代码结构来规避这个问题,但是此时我们的业务需求就是要这对同一个变量进行修改,没办法规避
所以,我们将目光放到第三个原因,我们是否可以想办法让count++操作变成原子的呢?

答案是可以的。可以通过加锁来实现。

2.8synchronized关键字

Java中加锁最常用的方法是,使用synchronized关键字。

synchronized的特性(1):互斥

synchronized使用的时候,通常会搭配一个代码块{},进入{就会加锁,出了}就会解锁,在一个已经枷锁的状态下,另一个线程尝试同样加这个锁,就会产生“锁竞争/锁冲突”,后一个线程就会阻塞等待,直到前一个线程释放锁。
image.png
()中表示一个用来加锁的对象,这个对象是啥不重要,重要的是通过这个对象来区分两个线程是否在竞争同一个锁。如果两个线程针对同一个对象加锁,就会有锁竞争;如果不是针对同一个对象加锁,就不会有锁竞争,仍然是“并发”执行。
我们对上面的代码进行加锁:
image.png
加锁之后,可以看到,执行结果就是我们预期的100000.
image.png
image.png
synchronized除了可以修饰代码块外,还可以修饰实例方法和静态方法。
修饰实例方法:
image.png
此时就相当于使用this作为锁对象,和下面这种写法是等价的。
image.png

class Counter {
    public int count;
    // synchronized 修饰一个实例方法
    synchronized public void increase() {
        count++;
    }
   
}
public class demo11 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        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);
    }
}

执行结果也是100000.
如果修饰静态方法,默认的锁对象就是类对象。
image.png
这两种写法也是等价的,上面的相当于下面的简化写法。

  • 再次强调:上面的这两种写法里,锁对象是啥不重要,重要的是两个线程中的锁对象是否是同一个锁对象。

synchronized的特性(2)可重入

可重入锁就是指,一个线程针对同一把锁连续加锁两次,不会出现“死锁”的情况,满足这个要求就是“可重入锁”,不满足就是“不可重入锁”。
image.png
第一次加锁假设能够加锁成功,此时locker就属于“被锁定“的状态,进入第二次加锁,很明显,locker已经处于锁定状态了,第二次加锁操作原则上来说就会处于“阻塞等待”的状态,等到锁被释放了,才能加锁成功。但是实际上,一旦第二次加锁的时候阻塞了,就会出现死锁情况(线程卡死了)。
第二次加锁要想加锁成功,就要需要等到第一次加锁释放锁;而第一次加锁要想释放锁,就需要执行完}(1)的位置,要执行到}(1),就需要第二次加锁成功,代码才能执行,由于第二次加锁导致代码阻塞了,没办法执行到}(1),也就没办法释放锁。
但是,Java中synchronized是“可重入锁”,就可以有效解决上述死锁问题。
“可重入锁”就是让锁记录一下,当前是被哪个线程锁住的,后续再加锁的时候,如果加锁线程就是当前持有锁的线程,就直接加锁成功.

  • 上述代码中,synchronized是可重入锁,没有因为第二次加锁而死锁,但是当代码执行到}(2)时,是否应该释放锁?
    很明显,不应该释放,如果}(2)和}(1)之间还有一些逻辑要执行,此时释放锁,就没有加锁保护,很可能产生线程安全问题。
  • 如果上述加锁过程中有N层,释放时机该如何判定?
    首先,无论有多少层,都要在最外层释放锁。我们可以引入计数器,在锁对象中,不光记录哪个线程加了锁,还要记录锁被加了几次,每加一次锁,计数器就+1,每解锁一次,计数器就-1,出了最后一个大括号,恰好减为0,才真正释放锁。
    关于死锁:
  1. 一个线程针对同一把锁,连续加锁两次,如果不是可重入锁,就会死锁
  2. 两个线程两把锁,此时无论是不是可重入锁,都会死锁(实例见下面代码)
  3. N个线程,M把锁,此时更容易出现死锁的情况(哲学家就餐问题)
public class demo12 {
    private static Object locker1 = new Object();
    private static Object locker2 = new Object();
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                // 此处的sleep很重要,如果没有sleep,很可能t1线程迅速将两把锁都加锁成功
                // 要确保t1 t2 都拿到一把锁之后,再开始后续动作
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                synchronized (locker2) {
                    System.out.println("t1 线程加锁成功");
                }
            }

        });
        Thread t2 = new Thread(() -> {
            synchronized (locker2) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                synchronized (locker1) {
                    System.out.println("t2 线程加锁成功");
                }
            }

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

很明显,两个线程都没有获取成功第二把锁,所以什么都没打印,就出现死锁了(注意:上述代码中,两个synchronized是嵌套关系,不是并列关系)
image.png
image.png
可以看到此时这两个线程的状态都是BLOCKED状态。
如何解决/避免死锁呢?
死锁的成因,涉及到四个必要条件:

  1. 互斥使用(锁的基本特性):当一个线程持有一把锁的时候,另一个线程,另一个线程也想获得到锁,就需要阻塞等待。
  2. 不可抢占(锁的基本特性):当锁已经被第一个线程拿到之后,第二个线程只能等待线程1主动释放锁,不能强行抢过来。
  3. 请求保持(代码结构):一个线程尝试获得多把锁(先拿到锁1之后,再尝试获取锁2,获取的时候,锁1不会释放)
  4. 循环等待/环路等待(代码结构):等待的依赖关系形成环了。

1和2是锁本身的特性,只要代码中把3和4占了,死锁就容易出现了。
所以,解决死锁就是要破坏上述的必要条件,只要破坏一个,死锁就形成不了。

  • 1和2破坏不了,它们是synchronized自带的特性
  • 对于3来说,调整代码结构,避免写“锁嵌套”逻辑(这个方案不一定好使,有的需求可能就是需要这种获取多个锁再操作)
  • 对于4来说,可以约定加锁顺序,就可以避免循环等待(比如针对锁进行编号,约定加多把锁的时候,先加编号小的锁,后加编号大的锁,所有线程都要遵守这个规则)

因此,我们可以针对上面这个代码约定加锁顺序,先加locker1,后加locker2

public class demo12 {
    private static Object locker1 = new Object();
    private static Object locker2 = new Object();
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                // 此处的sleep很重要,如果没有sleep,很可能t1线程迅速将两把锁都加锁成功
                // 要确保t1 t2 都拿到一把锁之后,再开始后续动作
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                synchronized (locker2) {
                    System.out.println("t1 线程加锁成功");
                }
            }

        });
        Thread t2 = new Thread(() -> {
            synchronized (locker1) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                synchronized (locker2) {
                    System.out.println("t2 线程加锁成功");
                }
            }

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

此时,加锁成功。
image.png

2.9volatile关键字

计算机运行程序/代码的时候,经常要访问数据,这些依赖的数据往往会存储在内存中(定义一个变量,变量就存储在内存中),cpu在使用这些变量的时候,就会把这个内存中的数据先读出来,放到cpu寄存器中,然后再参与运算(load)。但是cpu读取内存的这个操作其实是非常慢的(读内存相比于读硬盘快几千倍,上万倍;都寄存器相比于读内存又快了几千倍,上万倍),cpu进行大部分操作都很快,一旦涉及到读/写内存操作,速度一下就慢了下来。

为了解决上述问题,提高效率,此时编译器就可能对代码做出优化,把一些本来要读内存的操作优化成读寄存器,减少读内存的次数,也就可以提高程序整体的效率了。

public class demo13 {
    private static int isQuit = 0;
    public static void main(String[] args) {

        Thread t1 = new Thread(() -> {
            while (isQuit == 0) {
                // 什么都不做
                // 此时一秒钟会执行很多次循环
            }
            System.out.println("t1 线程退出");
        });
        t1.start();
        Thread t2 = new Thread(() -> {
           Scanner scanner = new Scanner(System.in);
            System.out.println("请输入isQUit");
        // 如果输入的isQuit不是0,t1线程就会执行结束
            isQuit = scanner.nextInt();
        });
        t2.start();
    }
}

此时,代码的预期效果是:用户输入非0值之后,t1线程退出。
image.png
但是,真正输入1之后,此时t1线程并没有结束!
很明显,实际效果和预期效果不一样,有bug,这也是由于多线程引起的线程安全问题。(此时是一个线程修改,一个线程读)
此处的问题就是”内存可见性“引起的。
image.png
这里的while操作涉及到两个步骤:
1)load 读取内存中的isQuit的值到寄存器中
2)通过cmp指令比较寄存器的值是否为0,决定是否要继续循环
由于这个循环,循环速度飞快,短时间内就会进行大量循环,也就是进行大量的load和cmp操作,此时,编译器/JVM就发现,虽然进行了这么多次load,但是load出的结果都一样,并且,load操作又非常费时间,一次load花费的时间可以进行上万次的cmp。所以,编译器就做了一个大胆的决定-----只有第一次循环的时候读了内存,后续就不再读内存了,而是直接从寄存器中取出isQuit的值。
image.png
后续,t2线程修改isQuit之后,t1感知不到isQuit的变化(感知不到内存的变化)。
这就是编译器优化
其实编译器优化的初心是好的,希望能够提高程序的效率,但是提高效率的前提是保证逻辑不变,此时由于修改isQuit的代码是另一个线程操作的,编译器没有正确判定,所以编译器以为没人修改isQuit,就做出了上述优化,也就进一步引起Bug了。
这个问题就是”内存可见性“问题。这里其实是编译器优化错了。
volatile就是解决方案。
在多线程环境下,编译器对于是否要进行这样的优化,判定不一定准,就需要程序员通过volatile关键字告诉编译器,此时不要优化,优化是算的快了,但是不一定算得准。
image.png
加上之后,程序就可以顺利退出了。
image.png
关于内存可见性,还涉及一个关键的概念,JMM(Java Memory Model,Java内存模型):
主内存(我们平时说的内存),工作内存(包括cpu寄存器和缓存)。
t1线程对应的变量,本身是存储在主内存中的,由于此处的优化,就会把isQuit放到工作内存中,进一步的,t2修改主内存中的isQuit,不会影响到t1的工作内存。
但是,volatile是不能不能保证原子性的

2.10wait 和 notify

wait 和 notify是多线程中一个比较重要的机制,是用来协调多个线程的执行顺序的。本身多个线程的执行顺序是随机的(系统随机调度,抢占式执行),很多时候,我们希望能够通过一定的手段,协调执行顺序。
join是影响到线程结束的先后顺序,相比之下,此处我们希望线程不结束,也能够有先后顺序的控制。

wait: 等待,让指定线程进入阻塞状态
notify: 通知,唤醒对应的阻塞状态的线程
wait 和 notify 都是Object类的方法,随便定义一个对象,都可以使用wait notify

public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        System.out.println("wait 之前");
        object.wait();
        System.out.println("wait 之后");
    }

image.png
此时我们看到程序报出一个异常—非法监视器状态异常(synchronized锁状态异常)。
这里要注意:

wait在执行的时候,要做三件事:

  1. 释放当前的锁
  2. 让线程进入阻塞
  3. 当线程被唤醒的时候,重新获取到锁

释放锁的前提是先加上锁,很明显当前object对象并没有加锁。
image.png
image.png
加上锁之后,可以看到执行到wait之后就阻塞等待了,直到其他线程调用notify来唤醒它。
image.png
可以看到此时这里的状态就是waiting状态。
使用notify()方法唤醒线程:

public static void main(String[] args) {
        Object object = new Object();

        Thread t1 = new Thread(() -> {
            synchronized (object) {
                System.out.println("wait 之前");
                try {
                    object.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("wait 之后");
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (object) {
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("进行通知");
                object.notify();
            }
        });
        
        t1.start();
        t2.start();
    }

image.png
使用wait notify也可以避免”线程饿死“

当线程调用wait方法的时候,wait内部本身会释放锁,并且进入阻塞,此时,这个线程就不会参与后续的锁竞争了,也会把锁释放出来让别的线程来获取。

调用wait不一定就只有一个线程调用,N个线程都可以调用wait,此时,当多个线程调用的时候,这些线程都会进入阻塞状态,唤醒也就有两种方式:
notify All 一次唤醒全部线程:(唤醒的时候,wait要涉及到一个重新获取锁的过程,也是需要串行执行的)
notify 是一次唤醒一个线程
wait除了默认的无参数版本外,还有一个带参数的版本,可以指定超时时间,避免线程无休止的等待下去。
image.png

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值