多线程知识汇总

补充

①什么是多线程?
进程是操作系统进行资源分配的最小单位(不包括CPU)。
线程是操作系统进行CPU时间片(CPU调度的)分配的最小单位。

②多线程程序的优势?
有可能提升速度——通过添加新的调度单位,可以争抢到更多的CPU资源。
在阻塞的场景下——通过添加新的调度单位,阻塞一个调度单位,不影响其他调度单位抢CPU。

③为什么运行同样的多线程程序,可能出现不一样的运行结果?随机性从何而来?
线程在CPU上执行时,什么时候被切换下来是随机的。
线程在争夺CPU时,哪个线程争到CPU是随机的。

④JVM内存的那些区域是线共享的?哪些又是线程私有的?
线程共享:方法区,堆,常量池。
线程私有:程序计数器(PC),JVM栈,本地方法栈。

⑤关于wait()的内部实现(大概):

public wait(){
		状态RUNNABLE --- WAITING;
		将自身放入o等待集;
		o.unlock;
		放弃CPU;
		//当被别的线程调用notify()重新执行代码时
		o.lock() //因为代码块还要解一次,所以这里要先加上锁
}

⑥在Java帮助文档中,wait()建议放入一个while循环里,在该模型中也要放在循环里,避免因为线程调度而发生的错误。假如:在if中那么,我们希望它没达到条件需要继续wait,而CPU很有可能“觉得”当前不需要继续wait()了,顺序执行下面代码。

同步队列和等待队列
简单的理解是同步队列存放着竞争同步资源的线程的引用(抢锁失败),而等待队列存放着待唤醒的线程的引用(调用wait())。

⑧散件知识
线程的优先级是一个建议权,并不是绝对的。
后台线程主要用来执行不是很着急,默默去做的工作比如GC。
JVM结束的标志是前台线程所有结束才结束,而不是主线程的结束。

思维导图

在这里插入图片描述

一、认识多线程——要点解析

1、多线程的创建方法

(1)继承Thread重写run()
class MyThread extends Thread {
@Override
public void run() {
		System.out.println("这里是线程运行的代码");
	}
}
public static void main(String args[]){
		MyThread t = new MyThread();
		t.start(); // 线程开始运行
}
(2)实现 Runnable 接口

1.将要执行的代码放入一个实现了Runnable接口的类中,在该类中重写run()方法。
2.调用 Thread 的构造方法时将 Runnable 对象作为 target 参数传入来创建线程对象。

class MyRunnable implements Runnable {
@Override
public void run() {
		System.out.println(Thread.currentThread().getName() + "这里是线程运行的代码");
	}
}
public static void main(String args[]){
		MyRunnable runnable = new MyRunnable();
		Thread thread = new Thread(runnable); 
		thread.start();
}

Tips :
实际上Thread的构造方法有如下几种:Thread();Thread(Runnable target);Thread(String name);Thread(Runnable target,String name)
所以必要时也可以通过Thread的构造方法为线程起名字。

二、使用多线程——要点解析

1、Thread类中的常用方法

①启动一个线程:start();使线程的状态从NEW—>RUNNABLE,同时,把线程对象放入就绪队列,使其具有被调度的资格

②等待一个线程:join();是一个静态方法,当’‘调用线程’’ 执行 t.join() 时,"调用线程"会从CPU上下来,同时放弃抢CPU的资格,加入到阻塞队列,直到 t 线程完全执行结束。"调用线程"才从阻塞队列中出来编成就绪态,重新拥有抢CPU的资格,如果抢到CPU才会继续执行下去。

//1.A线程赋予B线程生命
B.start(); ---->A线程中调用
//2.A线程等待B线程停止后再继续
B.join();---->A线程中调用

③休眠一个线程:sleep(long mills);自身线程调用,单位为毫秒,从调度的角度上讲,会使线程进入阻塞队列,放弃争夺CPU的队列mills毫秒后,再次进入就绪队列,加入抢夺CPU的队列(抢CPU的资格)。

④下线一个线程:yield();当前线程从CPU上下来进入,直接返回到争夺CPU的队列。

A.sleep(5000); //A会在5秒后往下执行

④查看当前线程:currentThread();该方法为Thread类中的静态方法,在哪个线程中被调用了,返回的就是该线程的引用。方法如下(直接调用类的引用即可)

public class ThreadDemo {
public static void main(String[] args) {
Thread thread = Thread.currentThread();
System.out.println(thread.getName());
	}
}

⑤通知线程停止:interrupt();该方法是Thread类中的普通方法,通过实例对象的引用来调用,作用为让该线程对象停止。
isInterrupted();作用为判断线程对象停止标志位是否已经被设置。

2、多线程中的中断机制(A线程让B线程停止)

(1)自定义,设置共享标志位

如下

public class 一个线程停止{
	//共用的标志位,主线程和子线程都能看到和修改
	private static boolean condition;
	
	private static class Writer implements Runnable{
		public void run(){
			while(condition){
				System.out.println("写作业");
			}
		}
	}
	public static void main(String[] args){
		Scanner scan = new Scanner(System.in);
		
		condition = true;
		Thread t = new Thread(new Writer());
		t.start();
		
		scan.nextLine();//只要键盘输入就停止线程
		condition = false;
		System.out.println("通知子线程停止");
		t.join();
		System.out.println("子线程已经停止");
	}

}

可是当子线程中有sleep方法的时候,无法实时的响应。为了解决这个问题,引入了②。

(2) Java内置方法⭐

每个线程内部都有一个中断标志位,默认为false,非中断状态。标志位不会中断(停止)当前的线程,只是标志一下当前线程的某个状态值。

常用方法作用(假设A B 都为线程):
A.Interrupt() : 使A线程的中断标志位=true,即通知A线程停止。
A.isInterrupted() : 判断A线程是否收到了中断通知。但不改变A的中断标志位。
Thread.interrupted() : 判断当前线程是否收到了中断通知,改变当前线程的中断标志位。

小结:
♣ 当子线程没有sleep()方法时,不考虑线程休眠,与(1)中设置标志位的方法类似。

♣ 当子线程有sleep()方法时,采用之前的方案无法实时的响应通知,但是 当有休眠时,线程若接收到停止的通知,会抛出一个InterruptedException异常,可以利用这个机制,使得无论何时,子线程都有机会实时响应停止通知。但是主线程只会通知子线程,子线程停不停止是自己决定的(可以break,也可以无视通知)。

A.isInterrupted() 与 Thread.interrupted()的区别
Ⅰ.前者是普通方法,后者是静态方法。
Ⅱ.前者是用于第三方线程查看是否A线程收到停止通知;后者用于当前线程的自查。
Ⅲ.在某个线程调用完A.isInterrupted()后,A的标志位不会被重置;在线程调用完Thread.interrupted()方法后,线程内部的标志位被重置了。

public class 如何通知一个线程停止 {
    static class 写作业 implements Runnable {
        @Override
        public void run() {
        	Thread current = Thread.currentThread();
            while (!current.isInterrupted()) { // 如果没睡,通过这里知道要停止的消息
                try {
                    System.out.println("写第一份作业");
                    Thread.sleep(3 * 1000);
                    System.out.println("写第二份作业");
                    Thread.sleep(3 * 1000);
                    System.out.println("写第三份作业");
                    Thread.sleep(3 * 1000);
                    System.out.println("写第四份作业");
                    Thread.sleep(3 * 1000);
                    System.out.println("写第五份作业");
                    Thread.sleep(3 * 1000);
                } catch (InterruptedException e) {
                    // 如果进入sleep,通过异常知道要停止的消息

                    break; // 我主动停下来
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(new 写作业());
        t.start();

        Scanner scanner = new Scanner(System.in);
        scanner.nextLine();

        System.out.println("准备通知停止写作业");
        t.interrupt();
        System.out.println("已经通知停止写作业");
        t.join();	
        System.out.println("已经停止写作业");
    }
}

小结:本质上interrupt()并不能强制停止,只能通知停止。所以线程的停止主要理解他的机制。具体运用看情况。

3、线程的状态和状态转移

NEW :一个线程刚被创建的状态,例如Thread t = new Thread(),t的状态就是NEW。

RUNNABLE:该状态分为 Ready和Running,Ready代表线程拥有抢CPU的资格,Running代表该线程已经在CPU上运行,t.start()后就主要在这个 状态。

BLOCKEDWAITINGTIMED_WAITING:应为某些原因暂时失去抢CPU资格:①被高优先级抢走了②主动放弃了③时间片耗尽。

TERMINATED:线程的有效部分已经执行结束,但线程对象还在。

其中各种状态的转移如下图:
在这里插入图片描述

注意:
①除了RUNNABLE剩余的状态都不具备抢CPU的资格。
②yield()方法是线程从CPU上自行退出,进入被CPU调度的队列中,而sleep()是直接睡去了,连抢CPU的资格都没了。

4、线程安全⭐⭐⭐

概念: 多线程程序可以保证 100% 运行出期望的结果,说明程序是线程安全的。

线程不 安全的必要条件:1.有共享资源 2.针对共享资源有修改。

⭐⭐如何写出线程安全的代码(重要)
Ⅰ.设计多线程代码时,尽量不要让多线程之间共享资源——天生安全
Ⅱ.尽量只读取共享资源,而不要修改共享资源——天生安全
Ⅲ.如果仍然保证不了上面的两条,要使用相应机制保证原子性、可见性、重排序后的正确性。

(1)JVM中的线程安全

在JVM中:
程序计数器(PC)是每个线程私有的。
栈(本地方法栈和JVM栈)也是每个线程私有的。
堆,方法区,常量池是共享的。

数据有类型和形态,看一个数据是否是线程安全的,主要是看其形态,即在运行时JVM的内存区域,跟其数据类型无关。如果数据在PC上或在栈上,那么是线程安全的,如果在堆和方法区是不安全的。

(2)线程不安全的原因
①原子性

概念在CPU调度指令的过程中,指令的 效果 不能中间断开或被指令被分割后也未被其他线程干扰,这就是具备原子性。又言之,一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么被某些因素打断却不影响运行结果。

②内存可见性

概念:为了提高效率,JVM有其专有的一套工作方式,如下图。它会尽可能的将数据在工作内存中执行,但这样会导致一个问题——共享变量在多线程之间不能及时看到改变,这就是可见性问题。
在这里插入图片描述

③代码重排序

概念代码书写的顺序不一定是最终执行代码的顺序。书写单线程代码的情况下,编译器、JVM、CPU指令集会对其进行优化,使运行效率更快。但是在多线程情况下,CPU只能处理在一个时间处理一个线程,所以代码重排序可能会导致错误的结果。

(3)解决线程不安全问题的方法
①synchronized 关键字——监视器锁

Ⅰ.前置知识:JVM在实现的过程中,每一个对象都自带了一把锁,这把锁只属于这个对象本身。

Object o = new Object();//o这个引用指向的对象中就拥有一把它独有的锁

Ⅱ.作用
※ 原子性——在线程互斥的形况下,可以保证一组代码的原子性。结合图理解

内存可见性——某一个线程进入synchronized代码块前后,线程会获得锁,清空工作内存,从主内存拷贝共享变量最新的值到工作内存成为副本,执行代码,将修改后的副本的值刷新回主内存中,线程释放锁。

代码重排序——一个变量在同一时刻只允许一条线程对其进行lock操作。

Ⅲ.语法:1.一种是作为方法修饰符,修饰普通方法或静态方法,加锁的对象是该方法所在的类对象。

public class SynchronizedDemo {
public synchronized void methond() {
}
 public static void main(String[] args) {
		SynchronizedDemo demo = new SynchronizedDemo();
		demo.method(); // 进入方法会锁 demo 指向对象中的锁;出方法会释放 demo 指向的对象中的锁
	}
}

2.一种是作为同步代码块出现,加锁的对象是引用所指向的对象。

public class SynchronizedDemo {
public void methond() {
// 进入代码块会锁 this 指向对象中的锁;出代码块会释放 this 指向的对象中的锁
		synchronized (this) {
		}
}
 public static void main(String[] args) {
		SynchronizedDemo demo = new SynchronizedDemo();
		demo.method();
	}
}

关键点:
1)加锁的对象可以是任何对象,因为JVM中所有对象都有锁。Synchronized修饰普通方法那么争夺的是“this”引用指向对象中的锁,如果Synchronized修饰静态方法那么争夺的是“类.class”引用指向对象中的锁。
2)要实现互斥,不同的线程之间必须争夺的是一同把锁

Ⅳ.加锁的过程(简单理解):首先,synchronize会在大括号开始时插入一个字节码——lock,在大括号结束时插入一个字节码——unlock。假设A与B两个加锁的线程,因为某种原因,A在未执行完的情况下调度了B,那么B执行B中的 lock ,发现锁已经被A锁住了,无法lock到锁,B抢锁失败,进入到同步队列,B状态变为BLOCK,失去抢CPU的资格,等到A线程放弃锁,B拿到锁,才能再进入就绪状态,等待被CPU调度。

②volatile关键字

Ⅰ.前置知识:变量的赋值是否是原子性的?

int a = ...; int b = ... ;
a = b;//这条语句不是原子的,因为发生了两步:1.读取b的值(读) 2.将b的值赋值给a(写)
**********************************************
long a;
a = 10L;//不是原子的。
/*因为JVM每次操作的数据大小是按32位设计的
long类型是64位的所以,可以拆分为 a的高32位 = 0;
a的低32位 = 10;*/
**********************************************
引用类型 a;
a = null//是原子的

Ⅱ.作用

1.原子性(很局限)——被volatile修饰的变量是原子的。(有限:只有 long double)

2.※内存可见性——volatile保证不同线程对共享变量操作的可见性,也就是说一个线程修改了volatile修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值。
补充:每个线程操作数据的时候会把数据从主内存读取到自己的工作内存,如果他操作了数据并且写回了,他其他已经读取的线程的变量副本就会失效了,需要对数据进行操作又要再次去主内存中读取了。

3.限制代码重排序——保证对象的初始化不会发生重排序。保证 引用 = new 构造方法() 的有序性

解释如下:

Person p;
p = new Person("李话","男","12");

以上代码在运行时有如下步骤:
1)new:根据类进行内存分配,隐含计算对象大小,所有属性初始化为0等过程。
2)执行Person构造方法,获得实例。
3)把对象的引用赋给p这个引用。

考虑重排序,可能会出现如下执行顺序 1)— 2)— 3)或1)— 3)— 2),在单线程情况下,两种执行顺序并不会引发代码结果的变更。但在多线程情况下,可能出现问题,如下图,假设A线程按照1)3)2)顺序运行代码,B线程调用该对象,有可能会导致B线程调用未初始化的A对象。
在这里插入图片描述
如果使用volatile关键字修饰,就保证了对象初始化一定是按照:new—> Person() —> p =…的顺序。

Ⅲ.语法:只用来修饰属性 / 静态属性

volatile long a = 10;
volatile Person person = new Person();
③wait()与notify()

每个对象都有两个池,锁池和等待池,
Ⅰ.作用
都是Object的方法,不是Thread的方法,是所有类都具有的。

wait():其语义,一是该线程释放当前对象锁,另一个是进入等待队列。必须要其他线程调用notify()或notifyAll()才会从等待队列到达同步队列去争抢对象锁。

notify()/notifyAll() :用来唤醒 任意一个/全部 线程,你要去唤醒,首先你得知道他在哪儿,所以必须先找到该对象,也就是获取该对象的锁,当获取到该对象的锁之后,才能去该对象的对应的阻塞队列去唤醒一个线程。只有当执行唤醒工作的线程离开同步块,即释放锁之后,被唤醒线程才能去竞争锁。

使用场景:
wait()方法调用后,线程放弃当前锁并释放所有CPU资源,将线程处于等待队列中,其他线程调用notify()会从等待池唤醒任意一个线程并且放入同步队列,调用notifyAll()唤醒所有等待池中的线程并放入同步队列,但同步队列谁能抢到锁就不知道了。获得锁的线程将变成入就绪状态等待CPU调度。
在这里插入图片描述

Ⅱ.语法
因为不论是wait()还是notify()都会更改o指向对象的等待集,所以在语法上使用时,为了保持原子性,Java规定必须请求 o 这个对象上锁。

三、多线程的应用——要点分析

1、单例模式

单例模式是Java中的设计模式之一,它提供了一种创建对象的最佳化方法,它涉及到一个单一的类,这个类负责创建自己的对象,同时确保只有单个对象被创建。且这个类提供了一种访问他的唯一对象的方式,可以直接访问,不需要实例化该类的对象

(1)饿汉模式

立即初始化的模式
这个类是线程安全的,因为他发生在类初始化时期。

class Singleton {
private static Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
		return instance;
	}
}
(2)懒汉模式(延迟加载)

延时初始化的模式
如果不加锁操作,普通的懒汉模式是线程不安全的,因为instance == null 缺少了原子性,可能会返回多个对象。

class Singleton {
private static volatile Singleton instance = null; //防止重排序
private Singleton() {}
public static Singleton getInstance() {
	if (instance == null) {//如果没有对象再创建,如果有那就不创建
		synchronized (Singleton.class) {//
			if (instance == null) {/*二次判断是否有对象*/
				instance = new Singleton();
				}
			}
		} 
	return instance;
	}
}

2、阻塞式队列(Blocking Queue)

(1)生产者—消费者模型(自己实现)
该队列满足如下要求:

Ⅰ. 允许生产者把东西放入—— put()。
Ⅱ. 允许消费者把东西取出——take()。
Ⅲ. 当队列没满时,生产者可以将东西直接放入;如果队列满了,生产者需要等待,直到队列可以放入东西。
Ⅵ. 当队列没空时,消费者可以将东西直接取出;如果队列空了,消费者需要等待,直到队列可以取出东西。

代码思路:

(主要看代码,理解为主)
在消费者——生产者模型里,首先 put()和take()是一个循环数组,且都需要加锁,在put()中调用wait()的时候,take()中的notifyAll()来唤醒put()。使用notifyAll()而不使用notify()的原因在于,因为CPU调度切片的原因,可能导致的生产者和所有的消费者都被放入wait等待集,没有线程可以唤醒。

public class CircleQueue {
    private  int CAPACITY = 16;
    private int[] queue = new int[CAPACITY];
    private int size = 0;
    private int frontIndex = 0;
    private int rearIndex = 0;

    public synchronized void put(int element) throws InterruptedException {
        while (size==queue.length){ //判断队列是满了
            wait();//生产者阻塞
            
        }
        queue[rearIndex] = element;
        rearIndex = rearIndex + 1;
        if (rearIndex == queue.length){
            rearIndex = 0;
        }
        size++;
        notifyAll();//生产者试图唤醒消费者
    }


    public synchronized int take() throws InterruptedException {
        while (size == 0){//判断队列是否为空
            wait();//消费者阻塞
        }
        int element = queue[frontIndex];
        frontIndex += 1;
        if (frontIndex == queue.length){
            frontIndex = 0;
        }
        size--;
        notifyAll();//在多生产者多消费者模型中,防止生产者唤醒生产者,所有线程都进入阻塞队列这种情况

        return  element;

    }
}

注意:在Java帮助文档中,wait()建议放入一个while循环里,在该模型中也要放在循环里,避免因为线程调度而发生的错误。假如:在if中那么,我们希望它没达到条件需要继续wait,而CPU很有可能“觉得”当前不需要继续wait()了,顺序执行下面代码。

(2)JDK中自带的阻塞式队列

① BlockingQueue(接口)+ArraayBlockQueue(类):阻塞式循环队列。
② BlockingQueue(接口)+LinkedBlockingQueue(类):链表实现的阻塞队列,无最大容量限制。
③ BlockingQueue(接口)+PriorityBlockingQueue(类):堆,优先级队列实现的。

3、线程池

线程池是什么?

线程池的初步了解
线程池就像是一家公司,公司里有正式员工临时员工之分(线程),正式员工是线程池本来就有的线程,临时员工是用完就销毁的线程,提交任务(要完成的Runnable)给员工完成时,是一个阻塞队列,当任务堆积的太多,员工也有完成不完任务的时候。

线程池对待任务的流程
前台提交任务——execute(一个Runnable对象)
线程开始判断:
若当前正式员工数小于允许的最大员工数,说明可以继续雇佣正式员工
1.招聘正式员工——创建一个线程
2.把任务给该线程完成

否则说明,正式员工编制已经用完了
1.查看阻塞队列是否满了(因为不会轻易地去招聘临时员工的)
2.若没满,把任务放到阻塞队列中(留待正式员工腾出空间来完成)
若满了(说明固有的线程数量太少已经无法处理),判断当前所有员工数量和允许的最大员工数量的大小,如果可以就招募临时工把任务交给他完成,如果不行,说明没有办法,执行拒绝策略

了解JDK中自带的线程池各参数意义

ThreadPoolExecutor类就是JDK中的一个线程池类,也是常用的。
其构造方法如下:
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值