多线程-初阶(1)

本节⽬标
• 认识多线程
• 掌握多线程程序的编写
• 掌握多线程的状态
• 掌握什么是线程不安全及解决思路
• 掌握 synchronized、volatile 关键字

1. 认识线程(Thread)

1.1 概念

1) 线程是什么
⼀个线程就是⼀个 "执⾏流". 每个线程之间都可以按照顺序执⾏⾃⼰的代码. 多个线程之间 "同时" 执⾏着多份代码.
2) 为啥要有线程
⾸先, "并发编程" 成为 "刚需".

线程之间的共享变量存在 主内存 (Main Memory).
每⼀个线程都有⾃⼰的 "⼯作内存" (Working Memory) .
当线程要读取⼀个共享变量的时候, 会先把变量从主内存拷⻉到⼯作内存, 再从⼯作内存读取数据.
3) 进程和线程的区别

  1. 进程是包含线程的. 每个进程⾄少有⼀个线程存在,即主线程。
  2. 进程和进程之间不共享内存空间. 同⼀个进程的线程之间共享同⼀个内存空间。
  3. 进程是系统分配资源的最⼩单位,线程是系统调度的最⼩单位。
  4. ⼀个进程挂了⼀般不会影响到其他进程. 但是⼀个线程挂了, 可能把同进程内的其他线程⼀起带⾛(整个进程崩溃).

4) Java 的线程 和 操作系统线程 的关系
线程是操作系统中的概念. 操作系统内核实现了线程这样的机制, 并且对⽤⼾层提供了⼀些 API 供⽤⼾使⽤(例如 Linux 的 pthread 库).
Java 标准库中 Thread 类可以视为是对操作系统提供的 API 进⾏了进⼀步的抽象和封装.

1.2 第⼀个多线程程序

感受多线程程序和普通程序的区别:

• 每个线程都是⼀个独⽴的执⾏流
• 多个线程之间是 "并发" 执⾏的.

可以使用jconsole观察线程的状态,具体怎么使用后面学习.

1.3 创建线程


⽅法1 继承 Thread 类
继承 Thread 来创建⼀个线程类,

java 复制代码
class MyThread extends Thread {
     @Override
     public void run() {
         System.out.println("这⾥是线程运⾏的代码");
     }
}

创建 MyThread 类的实例

java 复制代码
MyThread t = new MyThread();

调⽤ start ⽅法启动线程

java 复制代码
t.start(); // 线程开始运⾏

⽅法2 实现 Runnable 接⼝

  1. 实现 Runnable 接⼝
java 复制代码
class MyRunnable implements Runnable {
     @Override
     public void run() {
         System.out.println("这⾥是线程运⾏的代码");
     }
}
  1. 创建 Thread 类实例, 调⽤ Thread 的构造⽅法时将 Runnable 对象作为 target 参数.

Thread t = new Thread ( new MyRunnable ());

方法3:匿名内部类,创建Thread子类

方法4:匿名内部类,创建Runnable子类

方法5:lambda表达式

2. Thread 类及常⻅⽅法

2.1 Thread 的常⻅构造⽅法

演示:

java 复制代码
Thread t1 = new Thread();
Thread t2 = new Thread(new MyRunnable());
Thread t3 = new Thread("这是我的名字");
Thread t4 = new Thread(new MyRunnable(), "这是我的名字");

2.1 Thread 的⼏个常⻅属性(重点)

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

解释:

ID 是线程的唯⼀标识,不同线程不会重复
名称是各种调试⼯具⽤到
状态表⽰线程当前所处的⼀个情况,下⾯我们会进⼀步说明
优先级⾼的线程理论上来说更容易被调度到(抢占资源)
关于后台线程,需要记住⼀点: JVM会在⼀个进程的所有⾮后台线程结束后,才会结束运⾏。
是否存活,即简单的理解,为 run ⽅法是否运⾏结束了
线程的中断问题,等等我们会解释到。

是否为后台线程:

一次都没有打印就结束,因为main线程结束的很快。mian是前台线程,thread是后台线程。也可以判断线程是否为后台线程。

是否存活:

2.2线程的中断(重点)

⽬前常⻅的有以下两种⽅式:

1. 通过共享的标记来进⾏沟通
2. 调⽤ interrupt() ⽅法来通知

标志位 中断(终止)

调用intterrupt()

解释

但是这里有一个问题:

会一直陷入死循环,为什么,

解释:

解决方法:

加上break

2.3等待⼀个线程 - join()

比如说:现在有两线程a,b,在a线程中调用b.join()

就是a线程等待b线程先结束,a线程在执行。

join的作用:就是能让先结束的线程先结束

2.4 获取当前线程引⽤

这个方法我们已经很熟悉了,用来获取当前线程的引用。

2.5休眠当前线程(sleep)

3. 线程的状态

打印线程状态:线程变量名.getState();

这个我就不多演示,后面我会给大家带来jcomsole工具,java自带工具的使用。

4. 多线程带来的的⻛险-线程安全 (重点)

4.1 观察线程不安全

比如说:现在让两个

代码:

java 复制代码
public class Demo9 {
    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();
        t1.join();
        t2.join();
        // 预期结果应该是 10w
        System.out.println("count: " + count);
    }
}

结果:

原因:

4.2 线程安全的概念

想给出⼀个线程安全的确切定义是复杂的,但我们可以这样认为:
如果多线程环境下代码运⾏的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

4.3 线程不安全的原因

线程调度是随机的
随机调度使⼀个程序在多线程环境下, 执⾏顺序存在很多的变数.
程序猿必须保证 在任意执⾏顺序下 , 代码都能正常⼯作
原⼦性

什么是原⼦性

我们把⼀段代码想象成⼀个房间,每个线程就是要进⼊这个房间的⼈。如果没有任何机制保证,A进⼊房间之后,还没有出来;B 是不是也可以进⼊房间,打断 A 在房间⾥的隐私。这个就是不具备原⼦性的。
是不是只要给房间加⼀把锁,A 进去就把⻔锁上,其他⼈是不是就进不来了。这样就保证了这段代码的原⼦性了。 有时也把这个现象叫做同步互斥,表⽰操作是互相排斥的。
⼀条 java 语句不⼀定是原⼦的,也不⼀定只是⼀条指令
⽐如刚才我们看到的 count++,其实是由三步操作组成的:

  1. 从内存把数据读到 CPU
  2. 进⾏数据更新
  3. 把数据写回到 CPU

可⻅性

可⻅性指, ⼀个线程对共享变量值的修改,能够及时地被其他线程看到.
Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.
⽬的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到⼀致的 并发效果.

  • 线程之间的共享变量存在 主内存 (Main Memory).

  • 每⼀个线程都有⾃⼰的 "⼯作内存" (Working Memory) .

  • 当线程要读取⼀个共享变量的时候, 会先把变量从主内存拷⻉到⼯作内存, 再从⼯作内存读取数据.

  • 当线程要修改⼀个共享变量的时候, 也会先修改⼯作内存中的副本, 再同步回主内存.

由于每个线程有⾃⼰的⼯作内存, 这些⼯作内存中的内容相当于同⼀个共享变量的 "副本". 此时修改线程1 的⼯作内存中的值, 线程2 的⼯作内存不⼀定会及时变化.

演示过程:

  1. 初始情况下, 两个线程的⼯作内存内容⼀致.
  1. ⼀旦线程1 修改了 a 的值, 此时主内存不⼀定能及时同步. 对应的线程2 的⼯作内存的 a 的值也不⼀定 能及时同步.

    这个时候代码中就容易出现问题.
    此时引⼊了两个问题:
    1) 为啥整这么多内存?
    实际并没有这么多 "内存". 这只是 Java 规范中的⼀个术语, 是属于 "抽象" 的叫法. 所谓的 "主内存" 才是真正硬件⻆度的 "内存". ⽽所谓的 "⼯作内存", 则是指 CPU 的寄存器和⾼速缓存.

2) 为啥要这么⿇烦的拷来拷去?
因为 CPU 访问⾃⾝寄存器的速度以及⾼速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级,也就是⼏千倍, 上万倍)。
⽐如某个代码中要连续 10 次读取某个变量的值, 如果 10 次都从内存读, 速度是很慢的. 但是如果只是 第⼀次从内存读, 读到的结果缓存到 CPU 的某个寄存器中, 那么后 9 次读数据就不必直接访问内存了. 效率就⼤⼤提⾼了.
那么接下来问题⼜来了, 既然访问寄存器速度这么快, 还要内存⼲啥??
答案就是⼀个字: 贵

4.4 解决之前的线程不安全问题

代码:

java 复制代码
public class Demo9 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            // 对 count 变量进⾏⾃增 5w 次
            for (int i = 0; i < 50000; i++) {
                synchronized (locker) {
                    count++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            // 对 count 变量进⾏⾃增 5w 次
            for (int i = 0; i < 50000; i++) {
                synchronized (locker) {
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // 预期结果应该是 10w
        System.out.println("count: " + count);
    }
}

结果:

5. synchronized 关键字 - 监视器锁 monitor lock

5.1 synchronized 的特性

1) 互斥

synchronized 会起到互斥效果, 某个线程执⾏到某个对象的 synchronized 中时, 其他线程如果也执⾏ 到同⼀个对象 synchronized 就会阻塞等待.

进⼊ synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁

synchronized⽤的锁是存在Java对象头⾥的。


理解 "阻塞等待".
针对每⼀把锁, 操作系统内部都维护了⼀个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试 进⾏加锁, 就加不上了, 就会阻塞等待, ⼀直等到之前的线程解锁之后, 由操作系统唤醒⼀个新的线程, 再来获取到这个锁。
注意:

上⼀个线程解锁之后, 下⼀个线程并不是⽴即就能获取到锁. ⽽是要靠操作系统来 "唤醒". 这也就 是操作系统线程调度的⼀部分⼯作.

假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B ⽐ C 先来的, 但是 B 不⼀定就能获取到锁, ⽽是和 C 重新竞争, 并不遵守先来后到的规则.
synchronized的底层是使⽤操作系统的mutex lock实现的。

2) 可重⼊

synchronized 同步块对同⼀条线程来说是可重⼊的,不会出现⾃⼰把⾃⼰锁死的问题;

理解 "把⾃⼰锁死"
⼀个线程没有释放锁, 然后⼜尝试再次加锁.
// 第⼀次加锁, 加锁成功
lock();
// 第⼆次加锁, 锁已经被占⽤, 阻塞等待.
lock();
按照之前对于锁的设定, 第⼆次加锁的时候, 就会阻塞等待. 直到第⼀次的锁被释放, 才能获取到第⼆个锁. 但是释放第⼀个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想⼲了, 也就⽆法进 ⾏解锁操作. 这时候就会 死锁。

这样的锁称为 不可重⼊锁.

Java 中的 synchronized 是 可重⼊锁,像c++和python是不可重入锁。

5.2 synchronized 使⽤⽰例

synchronized 本质上要修改指定对象的 "对象头". 从使⽤⻆度来看, synchronized 也势必要搭配⼀个具体的对象来使⽤.

1) 修饰代码块: 明确指定锁哪个对象.

锁任意对象 ,顾名思义只要是一个对象就行,无论是什么对象。

java 复制代码
public class SynchronizedDemo {
     private Object locker = new Object();
 
         public void method() {
         synchronized (locker) {
 
         }
     }
}

锁当前对象

java 复制代码
public class SynchronizedDemo {
   public void method() {
      synchronized (this) {
      }
   } 
}

2) 直接修饰普通⽅法: 锁的 SynchronizedDemo 对象

java 复制代码
public class SynchronizedDemo {
     public synchronized void methond() {
     }
}

3) 修饰静态⽅法: 锁的 SynchronizedDemo 类的对象

java 复制代码
public class SynchronizedDemo {
     public synchronized static void method() {
     }
}

我们重点要理解,synchronized 锁的是什么. 两个线程竞争同⼀把锁, 才会产⽣阻塞等待.
两个线程分别尝试获取两把不同的锁, 不会产⽣竞争.

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

Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, ⼜没有任何加锁措施,比如说:
ArrayList

LinkedList

HashMap

TreeMap

HashSet

TreeSet

StringBuilder

但是还有⼀些是线程安全的. 使⽤了⼀些锁机制来控制。

Vector (不推荐使⽤)

HashTable (不推荐使⽤)

ConcurrentHashMap

StringBuffer

StringBuffer 的核⼼⽅法都带有 synchronized。

还有的虽然没有加锁, 但是不涉及 "修改", 仍然是线程安全的

String

6. volatile 关键字

volatile 能保证内存可⻅性。
volatile 修饰的变量, 能够保证 "内存可⻅性".
按照翻译:

来我们写一个代码,只要输入0就会停止线程。

代码:

java 复制代码
public class Demo10 {
    static volatile int n = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while(true) {
                //啥都不写
            }
        });
        Thread t2 = new Thread(() -> {
            Scanner sc = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            n = sc.nextInt();
        });
    }
}

结果:

结果捏

这就是可见性问题。

原因:

volatile 不保证原⼦性

volatile 和 synchronized 有着本质的区别. synchronized 能够保证原⼦性, volatile 保证的是内存可⻅性.
再比如说:上面代码我们给count变量加上volatile保证可见性
保证内存可见性的目的是为了避免变量修改的时候被系统优化了。
代码:

java 复制代码
public class Demo9 {
    private volatile static  int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            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();
        t1.join();
        t2.join();
        // 预期结果应该是 10w
        System.out.println("count: " + count);
    }
}

结果:

好了今天就讲到这里。

相关推荐
远望清一色4 分钟前
基于MATLAB的实现垃圾分类Matlab源码
开发语言·matlab
confiself13 分钟前
大模型系列——LLAMA-O1 复刻代码解读
java·开发语言
Wlq041518 分钟前
J2EE平台
java·java-ee
XiaoLeisj25 分钟前
【JavaEE初阶 — 多线程】Thread类的方法&线程生命周期
java·开发语言·java-ee
杜杜的man28 分钟前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang
幼儿园老大*29 分钟前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
半桶水专家30 分钟前
go语言中package详解
开发语言·golang·xcode
llllinuuu30 分钟前
Go语言结构体、方法与接口
开发语言·后端·golang
cookies_s_s31 分钟前
Golang--协程和管道
开发语言·后端·golang
王大锤439133 分钟前
golang通用后台管理系统07(后台与若依前端对接)
开发语言·前端·golang