一、认识线程(Thread)
1.1 概念
1) 线程是什么
一个线程就是一个 “执行流”. 每个线程之间都可以按照顺序执行自己的代码. 多个线程之间 “同时” 执行着多份代码。
我们进一步设想如下场景:一家公司要去银行办理业务,既要进行财务转账,又要进行福利发放,还得进行缴社保。如果只有张三一个会计就会忙不过来,耗费的时间特别长。为了让业务更快的办理好,张三又找来两位同事李四、王五一起来帮助他,三个人分别负责一个事情,分别申请一个号码进行排队,自此就有了三个执行流共同完成任务,但本质上他们都是为了办理一家公司的业务。此时,我们就把这种情况称为多线程,将一个大任务分解成不同小任务,交给不同执行流就分别排队执行。其中李四、王五都是张三叫来的,所以张三一般被称为主线程(Main Thread)。
2) 为啥要有线程
首先, “并发编程” 成为 “刚需”。单核 CPU 的发展遇到了瓶颈。要想提高算力, 就需要多核 CPU. 而并发编程能更充分利用多核 CPU资源。有些任务场景需要 “等待 IO”, 为了让等待 IO 的时间能够去做一些其他的工作, 也需要用到并发编程。其次, 虽然多进程也能实现并发编程, 但是线程比进程更轻量。
- 创建线程比创建进程更快。
- 销毁线程比销毁进程更快。
- 调度线程比调度进程更快。
最后, 线程虽然比进程轻量, 但是人们还不满足, 于是又有了 “线程池”(ThreadPool) 和 “协程” (Coroutine)
3) 进程和线程的区别
进程是包含线程的. 每个进程至少有一个线程存在,即主线程。
进程和进程之间不共享内存空间. 同一个进程的线程之间共享同一个内存空间。 比如之前的多进程例子中,每个客户来银行办理各自的业务,但他们之间的票据肯定是不想让别人知道的,否则钱不就被其他人取走了么。而上面我们的公司业务中,张三、李四、王五虽然是不同的执行流,但因为办理的都是一家公司的业务,所以票据是共享着的。这个就是多线程和多进程的最大区别。
进程是系统分配资源的最小单位,线程是系统调度的最小单位。

4) Java 的线程 和 操作系统线程 的关系
线程是操作系统中的概念. 操作系统内核实现了线程这样的机制, 并且对用户层提供了一些 API 供用户使用(例如 Linux 的 pthread 库)。Java 标准库中 Thread 类可以视为是对操作系统提供的 API 进行了进一步的抽象和封装.
1.2调度策略
1. 时间片

2. 抢占式:高优先级的线程抢占CPU
Java的调度方法
1. 同优先级线程组成先进先出队列(先到先服务),使用时间片策略
2. 对高优先级,使用优先调度的抢占式策略
二、线程的创建和启动
Java语言的JVM允许程序运行多个线程,它通过java.lang.Thread类来体现。
每个线程都是一个独立的执行流 。
多个线程之间是 “并发” 执行的。(这里的并发指 并发 + 并行 )
Thread类的特性
每个线程都是通过某个特定Thread对象的run()方法来完成操作的,经常把run()方法的主体称为线程体。通过该Thread对象的 start() 方法来启动这个线程,而非直接调用run()
2.1 多线程的创建
方法一: 继承于Thread类
1. 创建一个继承于Thread类的子类
2. 重写Thread类的run() --> 将此线程执行的操作声明在run()中
3. 创建Thread类的子类的对象
4. 通过此对象调用start()
例子:遍历100以内的所有偶数
代码如下:
//1. 创建一个继承于Thread类的子类
class MyThread extends Thread {
//2. 重写Thread类的run()
@Override
public void run() {
for (int i = 0; i < 100; i++) {
if(i % 2 == 0){
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
}
public class ThreadTest {
public static void main(String[] args) {
//3. 创建Thread类的子类的对象
MyThread t1 = new MyThread();
//4.通过此对象调用start(): ①启动当前线程 ②调用当前线程的run()
t1.start();
//问题一:我们不能通过直接调用run()的方式启动线程
// t1.run();
//问题二:再启动一个线程,遍历100以内的偶数。不可以还让已经start()的线程去执行。会报IllegalThreadStateException
// t1.start();
//我们需要重新创建一个线程的对象
MyThread t2 = new MyThread();
t2.start();
//如下操作仍然是在main线程中执行的。
for (int i = 0; i < 100; i++) {
if(i % 2 == 0){
System.out.println(Thread.currentThread().getName() + ":" + i + "***********main()************");
}
}
}
}
方式二:实现Runnable接口
1) 定义子类,实现Runnable接口。
2) 子类中重写Runnable接口中的run()方法。
3) 创建实现类的对象
4) 将Runnable接口的子类对象作为实际参数传递给Thread类的构造器中,创建Thread类的对象。
5) 调用Thread类的start方法:开启线程,调用Runnable子类接口的run方法。
//1. 创建一个实现了Runnable接口的类
class MThread implements Runnable{
//2. 实现类去实现Runnable中的抽象方法:run()
@Override
public void run() {
for (int i = 0; i < 100; i++) {
if(i % 2 == 0){
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
}
public class ThreadTest1 {
public static void main(String[] args) {
//3. 创建实现类的对象
MThread mThread = new MThread();
//4. 将此对象作为参数传递到 Thread类的构造器中,创建Thread类的对象
Thread t1 = new Thread(mThread);
t1.setName("线程1");
//5. 通过Thread类的对象调用start():① 启动线程 ②调用当前线程的run()-->调用了Runnable类型的target的run()
t1.start();
//再启动一个线程,遍历100以内的偶数
Thread t2 = new Thread(mThread);
t2.setName("线程2");
t2.start();
}
}
2.2 比较创建线程的两种方式
开发中优先选择实现Runnable接口的方式
原因:
1、实现的方式没有类的单继承性的局限。
2、实现的方式更适合来处理多个线程有共享数据的情况。
3、解耦合,将线程的实现和线程的创建分开。
联系:
public class Thread implements Runnable
相同点:
两种方式都需要重写run(),将线程要执行的逻辑声明在run()中。
2.3 其他变形创建方式
// 使用匿名类创建 Thread 子类对象
Thread t1 = new Thread() {
@Override
public void run() {
System.out.println("使用匿名类创建 Thread 子类对象");
}
};
// 使用匿名类创建 Runnable 子类对象
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("使用匿名类创建 Runnable 子类对象");
}
});
// 使用 lambda 表达式创建 Runnable 子类对象
Thread t3 = new Thread(() -> System.out.println("使用匿名类创建 Thread 子类对象"));
Thread t4 = new Thread(() -> {
System.out.println("使用匿名类创建 Thread 子类对象");
});
三、练习
创建两个分线程,其中一个线程遍历100以内的偶数,另一个线程遍历100以内的奇数
代码如下:
class MyThread1 extends Thread{
@Override
public void run() {
for (int i = 0; i < 100; i++) {
if(i % 2 == 0){
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
}
class MyThread2 extends Thread{
@Override
public void run() {
for (int i = 0; i < 100; i++) {
if(i % 2 != 0){
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
}
public class ThreadDemo {
public static void main(String[] args) {
//方式一:分别实例化对象m1,m2
// MyThread1 m1 = new MyThread1();
// MyThread2 m2 = new MyThread2();
// m1.start();
// m2.start();
//方式二:创建Thread类的匿名子类的方式
new Thread(){
@Override
public void run() {
for (int i = 0; i < 100; i++) {
if(i % 2 == 0){
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
}.start();
new Thread(){
@Override
public void run() {
for (int i = 0; i < 100; i++) {
if(i % 2 != 0){
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
}.start();
}
}
四、Thread类的有关方法
4.1 Thread类的构造方法

4.2 Thread类的基本属性
| 属性 | 获取方法 |
|---|---|
| ID | getId() |
| 名称 | getName() |
| 状态 | getState() |
| 优先级 | getPriority() |
| 是否后台线程(守护线程) | isDaemon() |
| 是否存活 | isAlive() |
| 是否被中断 | isInterrupted() |
一个线程:
- 在JVM中有个id
- 在操作系统的线程API中有个id
- 内核PCB中有个id
效果一样,但是是在不同环境中使用的
默认创建的线程是“前台线程”
前台线程会阻止进程退出,如果main运行完了,前台线程还没完,进程不会退出。
后台进程不会阻止进程退出,如果main等其他前台线程运行完了,即使后台线程还没完,进程也会退出。
线程是否存活isAlive() 判定内核的线程在不在
Thread对象虽然和内核中的中的线程是一一对应的关系,但是生命周期并非完全相同。Thread对象创建了,内核里的线程还不一定有,调用start方法,内核线程才有。当内核里的线程执行完了( run() 运行完了),内核的线程就销毁了.但是Thread对象还存在。
4.3 Thread类的有关方法
- void start(): 启动线程,并执行对象的run()方法
- run(): 线程在被调度时执行的操作
- String getName(): 返回线程的名称
- void setName(String name):设置该线程名称
- static Thread currentThread(): 返回当前线程。在Thread子类中就是this,通常用于主线程和Runnable实现类
- static void yield():线程让步
- 暂停当前正在执行的线程,把执行机会让给优先级相同或更高的线程
- 若队列中没有同优先级的线程,忽略此方法
- join():当某个程序执行流中调用其他线程的 join() 方法时,调用线程将
被阻塞,直到 join() 方法加入的 join 线程执行完为止。低优先级的线程也可以获得执行- static void sleep(long millis):(指定时间:毫秒)
- 令当前活动线程在指定时间段内放弃对CPU控制,使其他线程有机会被执行,时间到后重新排队。
- 抛出InterruptedException异常
- stop(): 强制线程生命期结束,不推荐使用
- boolean isAlive():返回boolean,判断线程是否还活着
注意:调用start方法才会真正创建线程,在内核中创建PCB!!!
4.4线程的优先级
1. 线程的优先级等级
MAX_PRIORITY:10
MIN _PRIORITY:1
NORM_PRIORITY:5 默认优先级
2. 获取和设置当前线程的优先级:
getPriority(): 获取线程的优先级
setPriority(int p): 设置线程的优先级
说明:高优先级的线程要抢占低优先级线程cpu的执行权。但是只是从概率上讲,高优先级的线程高概率的情况下 被执行。并不意味着只有当高优先级的线程执行完以后,低优先级的线程才执行。
4.5 start和run的区别
直接调用run并没有创建线程,只是在原来的线程中执行方法,调用start则是创建了线程,在线程中执行方法,和原来的线程是并发的。
4.6 线程中断
常见的有以下两种方式:
- 通过共享的标记来进行沟通
- 调用 interrupt() 方法来通知
4.6.1 通过共享的标记
public class ThreadDemo1 {
private static boolean flag=false;
public static void main(String[] args) {
Thread t=new Thread(() ->{
while(!flag){
System.out.println("hello");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t线程执行完");
});
t.start();
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag=true;
System.out.println("通过flag让t线程执行结束");
}
}

4.6.2 调用interrupt()方法
public class ThreadDemo4 {
public static void main(String[] args) {
Thread t=new Thread(() ->{
while(!Thread.currentThread().isInterrupted()){
System.out.println("hello");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
//如果没有break; 执行效果如下第一张图
break;
}
}
System.out.println("t线程执行完");
});
t.start();
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
t.interrupt();
System.out.println("通过interrupt()让t线程执行结束");
}
}

interrupt方法的行为有两种情况:
- t线程在运行状态会设置Thread.currentThread().isInterrupted()标志位为true。
- t线程在阻塞状态设置标志位后sleep/wait等阻塞方法会将标志位清除,而且抛出InterruptedException异常,通过这个异常将sleep提前唤醒。
如果线程因为调用 wait/join/sleep 等方法而阻塞挂起,则以InterruptedException异常的形式通知,清除中断标志。
当出现 InterruptedException 的时候, 要不要结束线程,取决于 catch 中代码的写法。 可以选择忽略这个异常, 也可以跳出循环结束线程。

4.7 线程等待
通过join方法控制线程之间结束的顺序
public class ThreadDemo5 {
public static void main(String[] args) {
Thread t=new Thread(() -> {
for (int i = 0; i < 5; i++) {
System.out.println("hello");
}
});
t.start();
System.out.println("main线程join之前");
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("main线程join之后");
}
}
在main中调用t.join效果就是让main线程阻塞等待,等t执行完了,main才继续执行。

main和t线程谁先执行不确定,主要看操作系统的调度
但是,大概率先打印main,由于start之后,内核创建线程是有开销的,为了执行新线程的代码,需要做一些准备工作。

五、线程的状态
在操作系统中,对于PCB有一个状态的描述。Java中觉得自带的状态不是特别合适,重新定义了一套状态规则
5.1 线程的所有状态
线程的状态是一个枚举类型 Thread.State
public class ThreadState {
public static void main(String[] args) {
for (Thread.State state : Thread.State.values()) {
System.out.println(state);
}
}
}
- NEW: 对象创建出来了,但是PCB内核还没创建(没有真正的创建线程)
- RUNNABLE: 就绪状态(正在CPU上运行+在就绪队列中排队)
- BLOCKED:等待锁的时候进入的阻塞状态
- WAITING:特殊的阻塞状态,调用wait
- TIMED_WAITING: 按照一定的时间进行阻塞,sleep
- TERMINATED: 线程执行结束
5.2 线程状态和状态转移

简易图
5.3线程的生命周期
JDK中用Thread.State类定义了线程的几种状态,要想实现多线程,必须在主线程中创建新的线程对象。Java语言使用Thread类及其子类的对象来表示线程,在它的一个完整的生命周期中通常要经历如下的五种状态:
新建: 当一个Thread类或其子类的对象被声明并创建时,新生的线程对象处于新建状态
就绪:处于新建状态的线程被start()后,将进入线程队列等待CPU时间片,此时它已具备了运行的条件,只是没分配到CPU资源
运行:当就绪的线程被调度并获得CPU资源时,便进入运行状态, run()方法定义了线程的操作和功能
阻塞:在某种特殊情况下,被人为挂起或执行输入输出操作时,让出 CPU 并临时中止自己的执行,进入阻塞状态
死亡:线程完成了它的全部工作或线程被提前强制性地中止或出现异常导致结束

六、线程安全问题
以下代码是线程不安全的
class Counter {
public int count = 0;
void increase() {
count++;
}
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);
}
}

6.1线程安全的概念
给出一个线程安全的确切定义是复杂的,但我们可以这样认为:
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。
6.2线程不安全的原因
修改共享数据
上面的线程不安全的代码中, 涉及到多个线程针对 counter.count 变量进行修改.
此时这个 counter.count 是一个多个线程都能访问到的 “共享数据”
counter.count 这个变量就是在堆上. 因此可以被多个线程共享访问.

原子性
- 什么是原子性?
我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。

- 那我们应该如何解决这个问题呢?
只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性。
有时也把这个现象叫做同步互斥,表示操作是互相排斥的。
一条 java 语句不一定是原子的,也不一定只是一条指令
比如刚才我们看到的 n++,其实是由三步操作组成的:
- 从内存把数据读到 CPU
- 进行数据更新
- 把数据写回到 CPU
- 不保证原子性会给多线程带来什么问题?
如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。这点也和线程的抢占式调度密切相关。
可见性
JVM对代码的优化引入的bug,编译器对代码进行效率优化,逻辑不变。
可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到。
Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.
目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.

- 线程之间的共享变量存在 主内存 (Main Memory).
- 每一个线程都有自己的 “工作内存” (Working Memory) .
- 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
- 当线程要修改一个共享变量的时候,也会先修改工作内存中的副本, 再同步回主内存.
由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 “副本”. 此时修改线程1 的工作内存中的值, 线程2 的工作内存不一定会及时变化.
这只是 Java 规范中的一个术语, 是属于 “抽象” 的叫法。所谓的 “主内存” 才是真正硬件角度的 “内存”。 而所谓的 “工作内存”, 则是指 CPU 的寄存器和高速缓存。
代码顺序性
一段代码是这样的:
- 去宿舍拿电脑
- 去教室学习
- 去宿舍拿书
如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问题,可以少跑一次宿舍。这种叫做指令重排序。
编译器对于指令重排序的前提是 “保持逻辑不发生变化”. 这一点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了,多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.
七、synchronized 关键字-监视器锁
如何针对线程不安全问题进行反制?
如何做才能让代码线程安全?
通过特殊手段,让count++变成原子的
7.1synchronized 关键字

使用synchronized关键字来修饰普通方法
锁的 SynchronizedDemo 对象
class Counter {
public int count = 0;
synchronized void increase() {
count++;
}
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);
}
}

使用synchronized关键字来修饰代码块
1.锁当前对象
public class SynchronizedDemo {
public void method() {
synchronized (this) {
}
}
}
2.锁类对象
public class SynchronizedDemo {
public void method() {
synchronized (SynchronizedDemo.class) {
}
}
}
使用锁的时候一定要明确当前是针对哪个对象加锁!!!
在Java中,任意的对象都可以作为锁对象
我们写多线程代码的时候,不关心这个锁对象是谁,是哪种形态。只关心两个线程是不是锁同一个对象,是锁同一个对象就有锁竞争,锁不同对象就没有竞争。
使用synchronized关键字来修饰静态方法
锁的 SynchronizedDemo 类的对象
public class SynchronizedDemo {
public synchronized static void method() {
}
}
7.2 synchronized 的特性
1) 互斥
synchronized 会起到互斥效果, 某个线程执行到某个对象的synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待.
进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁

加锁不是说,CPU一鼓作气就执行完,中间也是有可能会存在调度切换的。但是即使t1切换走了,t2仍然是BLOCKED状态,无法在CPU上运行。
线程调度出CPU,但是锁没释放,还是无法执行这个方法。
synchronized用的锁是存在Java对象头里的。

可以粗略理解成, 每个对象在内存中存储的时候, 都存有一块内存表示当前的 “锁定” 状态(类似于厕 所的 “有人/无人”).
如果当前是 “无人” 状态, 那么就可以使用, 使用时需要设为 “有人” 状态.
如果当前是 “有人” 状态, 那么其他人无法使用, 只能排队
如果只给一个线程加锁,另一个不加锁,不起作用!!
一个线程加锁,不涉及“锁竞争”,也就不会阻塞等待,也就不会串行修改,还是并发修改。
public class Counter1 {
public int count = 0;
synchronized void increase1() {
count++;
}
void increase2() {
count++;
}
public static void main(String[] args) throws InterruptedException {
Counter1 counter = new Counter1();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase1();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase2();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}

2) 刷新内存
synchronized 的工作过程:
- 获得互斥锁
- 从主内存拷贝变量的最新副本到工作的内存
- 执行代码
- 将更改后的共享变量的值刷新到主内存
- 释放互斥锁
所以 synchronized 也能保证内存可见性。
3) 可重入
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;
理解 “把自己锁死” 一个线程没有释放锁, 然后又尝试再次加锁.
按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待.直到第一次的锁被释放, 才能获取到第二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁操作. 这时候就会死锁. 这样的锁称为 不可重入锁.
Java 中的 synchronized 是 可重入锁, 因此没有上面的问题.
public class ThreadDemo6 {
static class Counter{
public synchronized void increase(){
synchronized (this){
//
}
}
}
public static void main(String[] args) {
Counter counter=new Counter();
Thread t=new Thread(){
@Override
public void run() {
counter.increase();
}
};
t.start();
}
}
上述代码,一个线程针对一把锁,连续加锁两次。第一次加锁能够加锁成功,第二次加锁就会加锁失败,因为锁已经被占用。就会在第二次加锁这里阻塞等待,等到第一把锁被解锁,第二次加锁才能成功,而第一把解锁要求执行完synchronized代码块,也就是要求第二次加锁成功,形成死锁。
针对上述情况,产生死锁问题的就是不可重入锁,没有产生死锁就是可重入锁。

在可重入锁的内部, 包含了 “线程持有者” 和 “计数器” 两个信息.
如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增. 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)

1200

被折叠的 条评论
为什么被折叠?



