前文我们知道计算机中的每一个程序都对应着一个进程,进程是CPU申请资源的最小单位,那么线程是什么呢,线程中我们又能学习到什么新的知识呢?? 我们来一探究竟
1. 认识线程(Thread)
- 线程是什么
⼀个线程就是⼀个"执行流".每个线程之间都可以按照顺序执行自己的代码.多个线程之间"同时"执行着多份代码.
- 为啥要有线程
⾸先,"并发编程"成为"刚需",其次,多进程处理多个业务时,一个进程要经过多个步骤,创建进程,申请资源,加入PCB 链表,销毁进程,释放资源,把PCB 从链表中删除,对系统的性能影响比较大,涉及到内存和文件资源过多,所以我们就想要在一个进程申请了一份资源后,能不能有某个东西可以共享进程申请来的资源呢?所以我们引出了线程的概念,所以线程也叫做轻量级的进程
。
- 单核CPU的发展遇到了瓶颈.要想提高算力,就需要多核CPU.而并发编程能更充分利用多核CPU资源
- 有些任务场景需要"等待IO",为了让等待IO的时间能够去做⼀些其他的⼯作,也需要用到并发编程.
- 其次,虽然多进程也能实现并发编程,但是线程比进程更轻量.
线程较于进程的优点:
-
创建线程比创建进程更快.
-
销毁线程比销毁进程更快.
-
调度线程比调度进程更快.
- 进程和线程的区别
进程是包含线程的.每个进程至少有⼀个线程存在,即主线程。
进程和进程之间不共享内存空间.同⼀个进程的线程之间共享同⼀个内存空间.
进程是系统分配资源的最小单位,线程是CPU调度的最小单位。
⼀个进程挂了⼀般不会影响到其他进程.但是⼀个线程挂了,可能把同进程内的其他线程⼀起带⾛(整个进程崩溃).
- Java的线程和操作系统线程的关系
线程是操作系统中的概念.操作系统内核实现了线程这样的机制,并且对⽤⼾层提供了⼀些API供用户使用(例如Linux的pthread库).
Java 标准库中Thread类可以视为是对操作系统提供的API进行了进⼀步的抽象和封装
2. 如何创建多线程程序
2.1 方法1继承Thread类
java
public class Thread_lesson01_01 {
public static void main(String[] args) {
MyThread01 myThread01=new MyThread01();
myThread01.start();
}
}
class MyThread01 extends Thread{
@Override
public void run() {
while (true){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("my thread...");
}
}
}
2.2 方法2实现Runnable接口
java
//创建Runnable接口
class MyThread02 implements Runnable{
@Override
public void run() {
while(true){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("my thread...");
}
}
}
public class Thread_lesson01_02 {
public static void main(String[] args) {
//创建Thread类实例,调⽤Thread的构造⽅法时将Runnable对象作为target参数.
Thread thread=new Thread(new MyThread02());
thread.start();
}
}
2.3 匿名内部类实现线程
2.3.1 匿名内部类创建Thread子类对象
java
public class Thread_lesson01_05 {
public static void main(String[] args) {
Thread thread=new Thread(){
@Override
public void run() {
while (true){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("匿名内部类创建Thread子类对象");
}
}
};
thread.start();
}
}
2.3.2 匿名内部类创建Runnable子类对象
java
public class Thread_lesson01_06 {
public static void main(String[] args) {
Thread thread=new Thread(new Runnable() {
@Override
public void run() {
while (true){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("使用匿名类创建Runnable子类对象");
}
}
});
thread.start();
}
}
2.3.3 lambda表达式创建Runnable子类对象
java
public class Thread_lesson01_04 {
public static void main(String[] args) {
Thread thread=new Thread(()->{
while(true){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("通过匿名lambda表达式创建 my thread...");
}
});
thread.start();
}
}
在了解了如何创建线程对象后,我们产生了几个问题:
1.我们的任务是用run()方法重写的,为什么启动线程用的是start(),没有调用run()方法,为什么run()方法执行了??
2.我们是否可以直接调用run()方法而不通过start()呢??
这就要从start()方法和run()方法的区别来说了:
1.start()方法,真实的申请系统线程PCB,从而启动一个线程,参与CPU调度
2.run()方法,定义线程时指定线程要执行的任务,如果调用只是Java对象的一个普通方法而已
3.当进程中有两个或多个线程时,运行结果如何??产生这种结果是为什么??
我们通过一段代码来测试一下:
java
public class Thread_lesson01_03 {
public static void main(String[] args) {
//创建MyThread01线程实例
MyThread03 myThread03=new MyThread03();
//调用start方法启动线程
myThread03.start();
while (true){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("hhh main thread...");
}
}
}
class MyThread03 extends Thread{
@Override
public void run() {
while (true){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("hhh my thread...");
}
}
}
3. Thread的类及常见方法
Thread类是JVM 用来管理线程的一个类,每个线程都有唯一的Thread对象与之关联
3.1 Thread的常见构造方法
方法 | |
---|---|
Thread() | 创建线程对象 |
Thread(Runnable target) | 使用 Runnable 对象创建线程对象 |
Thread(String name) | 创建线程对象,并命名 |
Thread(Runnable target,String name) | 使用 Runnable 对象创建线程对象 ,并命名 |
我们使用一段代码来演示这四种常见的构造方法:
java
public class Thread_lesson02_01 {
public static void main(String[] args) throws InterruptedException {
MyThread myThread=new MyThread();
//方法1:Thread()
Thread t1 = new Thread(()->{
while (true){
System.out.println("Thread()...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
//方法2: Thread(Runnable target)
Thread t2 = new Thread(()->{
while (true){
System.out.println(Thread.currentThread().getName()+"Thread(Runnable target)...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
//方法3:Thread(String name)
Thread t3 = new Thread(()->{
while (true){
System.out.println(Thread.currentThread().getName()+"Thread(String name)...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"我是Thread(String name)");
//方法4:Thread(Runnable target,String name)
Thread t4 = new Thread(myThread,"我是Thread(Runnable target,String name)");
//启动线程
t1.start();
t2.start();
t3.start();
t4.start();
}
}
class MyThread implements Runnable{
@Override
public void run() {
while (true){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("使用 Runnable 对象创建线程对象");
}
}
}
在该代码运行的情况下,我们使用JDK中提供的工具查看当前创建的线程------>jconsole
两种方法打开该工具:
1.在cmd命令行窗口输入jconsole
- 在JDK 的bin目录下双击打开
打开查看线程的工具之后,我们来看一下线程的名字
在线程中如果不为线程取别名,线程名默认从Thread-0开始到Thread()-N,在开发环境中,我们建议为每一个线程取上别名,方便日后查找问题
3.2 Thread的几个常见属性
属性 | 获取方法 | 说明 |
---|---|---|
ID | getId() | JVM默认为线程对象产生的一个编号,是Java层面的,与PCB 区分开 |
名称 | getName() | |
状态 | getState() | Java层面定义的状态(NEW、 RUNNABLE、WAITING等等 ),与PCB 区分开 |
优先级 | getPriority() | 优先级⾼的线程理论上来说更容易被调度到 |
是否后台程序 | isDaemon() | 线程分为前台线程和后台线程,通过这个标识位来区分当前线程是前台还是后台 |
是否存活 | isAlive() | 表示的是系统中PCB是否销毁,与thread对应没啥关系 |
是否被中断 | isInterrupted() | 通过设置一个标志位让线程在执行时判断是否要退出 |
java
public class Thread_lesson02_02 {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
System.out.println(Thread.currentThread().getName() + ": 我还活着");
Thread.sleep(1 * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 退出循环
System.out.println(Thread.currentThread().getName() + ": 我即将死去");
});
System.out.println(" ID: " + thread.getId());
System.out.println(" 名称: " + thread.getName());
System.out.println(" 状态: " + thread.getState());
System.out.println(" 优先级: " + thread.getPriority());
System.out.println(" 后台线程: " + thread.isDaemon());
System.out.println(" 活着: " + thread.isAlive());
System.out.println(" 被中断: " + thread.isInterrupted());
// 启动线程,申请真正的PCB,参与CPU调度
thread.start();
System.out.println(" 状态: " + thread.getState());
}
}
3.3 启动一个线程 -start()
调⽤start⽅法,才真的在操作系统的底层创建出⼀个线程.
3.4 中断一个线程
为什么会存在中断线程这个方法呢??
比如:小明在网上遭到电信诈骗,警察蜀黍发现了,让转账中断,那么警察蜀黍该怎么通知他呢??这就涉及到我们的停止线程的方式了。
目前常见的有以下两种方式:
1.通过共享的标记来进行沟通
2.调⽤interrupt()方法来通知
示例-1: 使⽤自定义的变量来作为标志位.
需要给标志位上加volatile关键字
示例-2: 使用Thread.interrupted()或者Thread.currentThread().isInterrupted()代替⾃定义标志位.
3.5 等待一个线程-join()
有时,我们需要等待⼀个线程完成它的⼯作后,才能进⾏⾃⼰的下⼀步⼯作。例如,小明上课要交学费,等小明交了学费才能上学,这时我们需要⼀个⽅法明确等待线程的结束。
方法 | 说明 |
---|---|
public void join() | 等待线程结束 |
public void join(long millis) | 等待线程结束,最多等millis毫秒 |
public void join(long millis,int nanos) | 同理,但可以更高精度 |
我们通过一段代码来说明为什么要使用等待线程-join():
我们对一个数进行累加10亿次,查看使用串行执行和并行执行所需要的时间:
java
public class Thread_lesson02_03 {
//5亿
public static long count=500000000;
public static void main(String[] args) throws {
// 串行
serial();
// 并行
concurrency();
}
/**
* 串行
*/
public static void serial(){
long begin = System.currentTimeMillis();
long a=0l;
for (int i = 0; i < count; i++) {
a++;
}
for (int i = 0; i < count; i++) {
a++;
}
long end=System.currentTimeMillis();
System.out.println("串行耗费时间"+(end-begin));
}
/**
* 并行
*/
public static void concurrency() {
long begin = System.currentTimeMillis();
Thread thread1=new Thread(()->{
long a=0l;
for (int i = 0; i < count; i++) {
a++;
}
});
thread1.start();
Thread thread2=new Thread(()->{
long a=0l;
for (int i = 0; i < count; i++) {
a++;
}
});
thread2.start();
long end=System.currentTimeMillis();
System.out.println("并行耗费时间"+(end-begin));
}
}
这是因为此时并行耗费的时间并不是执行完的时间,而是创建两个线程所耗费的时间差:
此时我们就需要使用join()等待线程完成任务,我们将上述代码加以修改:
java
//等待线程执行完毕
thread1.join();
thread2.join();
3.6 获取当前线程引用
方法 | 说明 |
---|---|
public static currentThread(); | 返回当前线程对象的引用 |
3.7 休眠当前线程
我们常用的Thread.sleep(1000)就是休眠当前进程,让其缓慢执行
方法 | 说明 |
---|---|
public static void sleep(long millis) throws InterruptedException | 休眠当前进程 |
public static void sleep(long millis,int nanos) throws InterruptedException | 可以更高精度休眠当前进程 |
4. 线程的状态
4.1 观察线程的所有状态
线程的状态是⼀个枚举类型Thread.State
利用下列代码即可打印所有的线程状态:
java
public static void main(String[] args) {
for (Thread.State state : Thread.State.values()) {
System.out.println(state);
}
}
NEW
:表示创建好了一个Java线程对象,安排好了任务,但是还没有启动
RUNNABLE
:运行+就绪的状态,在执行任务时最常见的状态之一,在系统中有对应PCB
BLOCKED
:等待锁的状态,阻塞中的一种
WAITING
:没有等待时间,一直死等,直到被唤醒
TIMED_WAITING
:指定了等待时间的阻塞状态,过时不侯
TERMINATED
:结束,完成状态,PCB已经销毁,但是JAVA线程对象还在
4.2 线程状态之间的关系
5. 多线程带来的风险------线程安全
5.1 观察线程不安全
java
/**
* 使用两个线程,让count自增到10w
*/
public class Thread_lesson03_01 {
//定义一个变量,让其自增10w次
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread1=new Thread(()->{
for (int i = 0; i < 50000; i++) {
count++;
}
});
thread1.start();
Thread thread2=new Thread(()->{
for (int i = 0; i < 50000; i++) {
count++;
}
});
thread2.start();
//等待线程结束
thread1.join();;
thread2.join();
//结束后,打印计算的count值
System.out.println("count="+count);
}
}
5.2 线程安全的概念
想给出⼀个线程安全的确切定义是复杂的,
但我们可以这样认为:如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的
5.3 线程不安全的原因
1. 线程调度是随机的
由于线程的执行顺序无法人为控制,抢占式执行是造成线程安全问题的主要原因而且我们解决不了,完全是CPU自己调度而且和CPU核数有关
2. 修改共享数据
- 多个线程修改同一个变量,会出现线程安全问题
- 多个线程修改不同的变量,不会出现线程安全问题
- 一个线程修改一个变量,也不会出现线程安全问题
3. 原子性
我们在前面的MySQL学习过程中,知道了事务是有原子性的,比如对一个事务,如果不让他完全执行任务时,就对他操作,会造成幻读,不可读等等不好的效果,MySQL中我们通过隔离级别保证事务的原子性,原子性即 :事务要么全都执行,要么全都不执行
在线程中,我们知道⼀条Java语句不⼀定是原⼦的,也不⼀定只是⼀条指令
比如刚才我们看到的count++,其实是由三步操作组成的:
- 从内存把数据读到CPU (LOAD)
- 进行数据更新 (ADD)
- 把数据写回到CPU (STORE)
由于线程是抢占式执行的,此处通过时间线来助于理解线程的原子性:
下图,对线程的原子性助以理解:
由于执行CPU指令不是原子性的,导致这三条指令没有全部执行完成就被CPU调度走了
另外的线程加载到的值是一个原始值,当两个线程分别完成自增操作之后把值写回内存时发生了覆盖现象
4. 内存可见性
可见性指,⼀个线程对共享变量值的修改,能够及时地被其他线程看到.
Java虚拟机规范中定义了Java内存模型(JMM) ,如下图所示:
- 线程之间的共享变量存在主内存(MainMemory).
- 每⼀个线程都有自己的"⼯作内存"(WorkingMemory),且线程工作内存之间是隔离的,线程对共享变量的修改线程执行相互感知不到
- 当线程要读取⼀个共享变量的时候,会先把变量从主内存拷贝到⼯作内存,再从⼯作内存读取数据.
- 当线程要修改⼀个共享变量的时候,也会先修改⼯作内存中的副本,再同步回主内存.
- 工作内存是JAVA层面对物理层面的关于程序所使用到了寄存器的抽象
- 如果通过某种方式 让线程之间可以相互通信,称之为内存可见性
5.指令重排序
指令重排序概念:
重排序是编译器、JVM和CPU为了提高执行效率对指令顺序进行调整的现象。它在保证单线程语义不变的前提下,减少了读写操作,提升了程序运行速度,并且保证程序的运行结果是正确。重排序分为编译器优化、CPU重排序和内存系统的"重排序"。虽然带来性能提升,但也要注意其可能影响多线程环境中的数据一致性。
6. 如何解决线程不安全问题
1.线程的调度是随机执行的:硬件层面的,我们解决不了
2.修改共享数据:在真实业务场景中,很难避免多线程,提供效率,我们解决不了
3.原子性:我们可以通过🔒锁实现原子性,下文介绍
4.内存可见性:我们可以让进程之间通过一种通信关系,解决内存的不可见性
5.指令重排序:我们可以程序员直接指定那个先执行
能够解决 3、4、5 其中一项,我们的线程不安全问题就可以解决
7.synchronized
关键字
7.1 synchronized 的特性
synchronized 会起到互斥效果,某个线程执行到某个对象的synchronized中时,其他线程如果也执行到同⼀个对象synchronized就会阻塞等待.
- 进⼊synchronized修饰的代码块,相当于加锁
- 退出synchronized修饰的代码块,相当于解锁
synchronized⽤的锁是存在Java对象头⾥的。
可以粗略理解成,每个对象在内存中存储的时候,都存有⼀块内存表示当前的"锁定"状态(类似于厕所 的"有⼈/⽆⼈").
- 如果当前是"⽆⼈"状态,那么就可以使⽤,使⽤时需要设为"有⼈"状态.
- 如果当前是"有⼈"状态,那么其他⼈无法使用,只能排队
理解"阻塞等待"针对每⼀把锁,操作系统内部都维护了⼀个等待队列.当这个锁被某个线程占有的时候,其他线程尝试进⾏加锁,就加不上了,就会阻塞等待,⼀直等到之前的线程解锁之后,由操作系统唤醒⼀个新的线程, 再来获取到这个锁.
注意:
- 上⼀个线程解锁之后,下⼀个线程并不是⽴即就能获取到锁.⽽是要靠操作系统来"唤醒".这也就 是操作系统线程调度的⼀部分⼯作.
- 假设有ABC三个线程,线程A先获取到锁,然后B尝试获取锁,然后C再尝试获取锁,此时B和C 都在阻塞队列中排队等待.但是当A释放锁之后,虽然B⽐C先来的,但是B不⼀定就能获取到锁, ⽽是和C重新竞争,并不遵守先来后到的规则.
7.2 synchronize关键字的魔力
我们通过下列代码来探究synchronize的魔力吧!
7.2.1 代码1:不添加synchronize关键字,查看计算结果
java
public class Thread_lesson04_01 {
public static void main(String[] args) throws InterruptedException {
//初始化累加对象
Counter01 counter01=new Counter01();
//创建两个线程对一个变量进时累加
Thread thread1=new Thread(()->{
counter01.increase();
});
Thread thread2=new Thread(()->{
counter01.increase();
});
// 启动线程
thread1.start();
thread2.start();
//等待线程
thread1.join();
thread2.join();
System.out.println(counter01.count);
}
}
class Counter01 {
public int count=0;
public void increase(){
for (int i = 0; i < 50000; i++) {
count++;
}
}
}
7.2.2 代码2:给increase方法加上synchronize关键字
java
public class Thread_lesson04_02 {
public static void main(String[] args) throws InterruptedException {
Counter02 counter=new Counter02();
Thread thread1=new Thread(()->{
counter.increase();
});
Thread thread2=new Thread(()->{
counter.increase();
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("count="+counter.count);
}
}
class Counter02 {
public int count=0;
public synchronized void increase(){
for (int i = 0; i < 50000; i++) {
count++;
}
}
}
此时,thread1线程先获取了锁,方法执行完成之后thread2线程再获取锁,这样的情况是一个单线程运行状态,是把多线程转换为一个单线程,从而解决线程安全问题,但并不是我们想要的效果
7.2.3 代码3:给increase方法中的代码块加上synchronize关键字
java
public class Thread_lesson04_03 {
public static void main(String[] args) throws InterruptedException {
Counter03 counter=new Counter03();
Thread thread1=new Thread(()->{
counter.increase();
});
Thread thread2=new Thread(()->{
counter.increase();
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("count="+counter.count);
}
}
class Counter03 {
public int count=0;
public void increase(){
//在真实业务中,在执行锁代码块之前有很多的数据获取或其他的可以并行执行的逻辑
//1.从数据库中查询数据 selectAll()
//2.对数据进行处理 build()
//3.其他的不修改共享变量的方法
//......
//当执行到修改共享变量的逻辑时,再加锁
//被锁修饰的代码块用{}包裹,其中()中可以是任何对象,使用this就是当前调用该方法的对象
synchronized(this) {for (int i = 0; i < 50000; i++) {
count++;
}
}
}
}
虽然当前代码依旧是按串行执行了,但是在锁定的代码块前后,有其他的方法或代码可以进程执行
7.2.4 代码4:定义方法increase和方法increase1,increase方法用synchronize关键字修饰,线程1调用increase方法,线程2调用increase1方法
java
public class Thread_lesson04_04 {
public static void main(String[] args) throws InterruptedException {
Counter04 counter=new Counter04();
Thread thread1=new Thread(()->{
counter.increase();
});
Thread thread2=new Thread(()->{
counter.increase1();
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("count="+counter.count);
}
}
class Counter04 {
public int count=0;
public synchronized void increase(){
for (int i = 0; i < 50000; i++) {
count++;
}
}
public void increase1(){
for (int i = 0; i < 50000; i++) {
count++;
}
}
}
由于increase1方法不加锁,在对increase1方法方法执行时,increase1方法不需要锁即可执行,导致了线程不安全问题,具体情况如下:
总结 :关于synchronized
1.被 synchronized 修饰的代码会变成串行执行
2.synchronized 可以去修饰方法,也可以修饰代码块
3.被synchronized 修饰的代码并不是一次性在CPU上执行完,而是中途可能会被CPU调度走,但是只有当所有的指令执行完成之后才会释放锁
4.只给一个线程加锁,也会出现线程安全问题
了解完了synchronize关键字后,我们来谈谈何为锁,锁又有哪些知识呢??
8. 锁🔒
我们知道,事务的隔离级别是通过锁和MVCC机制保证的,那么Java当中锁是用什么来实现的呢?锁存放在哪里呢??
8.1 锁是如何解决线程安全问题的?
- 解决线程非原子性
通过synchronize给方法加锁解决了原子性问题
- 解决内存不可见性
后一个线程永远读到的是上一个线程存放到主内存的值,通过这样的方式实现了内存可见性,
并没有对内存可见性做技术上的处理
- 解决不了重排序问题
8.2 锁存放的位置
在Java虚拟机中,对象在内存中的结构可以划分为4个区域
- markword: 对象头:锁信息、GC(垃圾回收)次数,程序计数器,一般为8BYTE
- 类型指计:当时的对象是哪个类,一般为4BYTE
- 实例数据:成员变量,不定
- 对齐填充:一个对象所的占的内存必须是8byte的整数倍,根据实例数据确定