多线程必备基础
Posted LuckyWangxs
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了多线程必备基础相关的知识,希望对你有一定的参考价值。
多线程基础
1、概念
1.1 什么是线程
线程是进程中的一个实体,线程本身是不会独立存在的。进程是代码在数据集合中的一次运行活动,一个Java程序进入内存运行时,就变成了一个进程。一个进程至少有一个线程,例如我们启动一个简单的main函数,就是启动了一个JVM进程,在这个进程中只有一个线程,就是我们所说的main主线程。进程中,多个线程共享进程的资源。
线程的本质是进程中一段并发操作的代码,操作系统在分配资源的时候是把资源分配给进程的,但是CPU比较特殊,它是被分配到线程的,因为真正要占用CPU运行的是线程,所以也说线程是CPU分配的基本单位。
进程与线程的关系如下图
学过JVM的应该都知道,一个进程内有多个线程,线程之间共享堆内存与方法区/元数据区中的资源,而栈与程序计数器是线程私有的。
程序计数器是一块较小的内存空间,用来记录线程当前要执行的指令地址,字节码解释器工作时就是通过改变计数器的值来选取下一条需要执行的字节码指令地址,循环、分支、跳转、线程恢复都依赖与程序计数器。
那么程序计数器为什么要设计成线程私有呢?上面说了,线程是占用CPU的基本单位,而CPU一般是使用时间轮转的方式让线程轮询占用的,所以当前线程CPU时间片用完后,要让出CPU,等到下次轮到自己的时候再执行。那么如何知道之前的线程执行到哪里了呢?其实程序计数器就是为了记录该线程让出CPU时的执行指令地址,待再次分配到时间片时,线程就可以从自己私有的程序计数器指定的地址继续执行。
需要注意的是,如果执行的是navtive方法,那么程序计数器记录的是undefined地址,只有执行的是Java方法时,程序计数器记录的才是指令地址。
1.2 线程的创建与运行
Java中有三种线程创建的方式,分别为实现Runable接口的run方法,继承Thread类并重写run方法,使用FutureTask方法
继承Threa方式
public class MyThread extends Thread {
@Override
public void run() {
System.out.print("this is a thread")
}
}
// 启动
MyThread t = new MyThread();
t.start();
需要注意的是当创建完了t对象后,线程并没有启动,此时只是新建状态,当调用了start()方法后,也没有被立即执行,此时为就绪状态,当线程获取了CPU时间片后,此时才从就绪状态转为运行状态,当run里的逻辑执行结束,线程便为死亡状态,也为终止状态。
使用这种方式的好处是,在run方法中直接用this就可以指代当前线程,无需使用Thread.currentThread()方法;不好的地方是Java不支持多继承,如果继承了Thread类,就不能再继承其他的类。另外,如果多个线程执行同一任务,就必须创建多个t对象,那共享资源需要被静态修饰。而Runable没有这种情况
实现Runable接口
public class MyRunable implements Runable {
@Override
public void run() {
System.out.print("this is a thread")
}
}
MyRunable r = new MyRunable();
Thread t1 = new Thread(r);
t1.start();
Thread t2 = new Thread(r);
t2.start();
此种方式为实现接口的方式,这样就避免了因为单继承而丢失的类拓展性的问题。我们可以看到启动是将r对象作为参数传给Thread实例,然后用t对象进行启动,多个线程启动统一任务只需要创建多个Thread对象,并把同一个r对象作为参数传入即可,不好的是,这样我们获取当前线程的时候需要用Thread.currentThread()方法。另外此种方式无法抛出异常,也没有办法获取返回值。下面的方式就可以
用FutureTask方法
public class MyCall implements Callable<Integer> {
@Overrid
public Integer call() throws Exception {
return 1;
}
}
//启动
MyCall c = new MyCall();
FutureTask<Integer> ft = new FutureTask<>(c);
Thread t = new Thread(ft);
t.start();
Integer i = ft.get();
此种方式不仅可以通过FutureTask获取到线程执行完的返回值,并且线程执行过程中可以抛出异常
1.3 死锁
死锁是指两个或两个以上的线程之间相互等待共享资源释放的现象
死锁产生的四个必要条件
- 互斥:
指线程已经获取到的一个只能有一个线程占用的共享资源 - 请求并持有
指线程已经占有互斥资源,在不释放互斥资源的情况下又重新请求使用新的互斥资源的请求 - 不可剥夺
指线程在执行结束前,自己占有的互斥资源不能被其他线程抢占 - 环路等待
即A线程占有a互斥资源,B线程占有b互斥资源,但A又需要B线程占有的b互斥资源,B同样又需要A占有的a资源
形成了环路等待。
1.4 守护线程与用户线程
Java中的线程分为两类,一类是daemon线程(守护线程),另外一类是user线程(用户线程)。在main方法启动后,就会有一个主线程,它就是用户线程。其实JVM内部同时还启动了很多守护线程,比如垃圾回收线程。
守护线程并不会影响JVM的正常退出,只要有一个用户线程还没结束,正常情况下JVM就不会结束。
创建守护线程的方式如下:
Thread thread = new Thread(new Runable() {
public void run() {
}
})
thread.setDaemon(true);
thread.start();
只要将daemon设置为true就可以
1.5 Join、Yield、Sleep
1.5.1 join
在项目实践中经常会遇到一个场景,就是需要等待几件事完成之后才能继续往下执行,例如,多个线程加载资源,需要等待多个资源加载完毕再汇总处理。join()方法就可以做这个事情
等待线程执行终止的方法,它是Thread类直接提供的,无参,如下代码例子
public static void main(String[] args) throws InterruptedException {
Thread threadOne = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e .printStackTrace();
System out println(" child threadOne over !");
}
});
Thread threadTwo = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e .printStackTrace();
System out println(" child threadTwo over !");
}
});
//启动子线程
threadOne.start() ;
threadTwo.start() ;
System.out.println("wait all child thread over !");
//等待子线程执行完毕, 返回
threadOne.join();
threadTwo.join();
System.out.println("all child thread over");
如上代码,在主线程里启动了两个子线程,然后分别调用了他们的join方法,那么主线程首先会在调用threadOne.join()方法后被阻塞,等待threadOne线程执行完毕,之后threadTwo调用他的join方法,同样使主线程等待它执行完毕。这里只是为了演示join方法的作用,这种情况使用CountDownLatch会更好
注意: 当线程A调用线程B的join方法后,会使自己阻塞,如果此时其他线程调用了线程A的interrupt()中断方法中断了线程A,则线程A会抛出InterruptedException异常
1.5.2 Sleep
Thread提供了一个名为sleep的静态方法,它能够使线程睡眠,即调用sleep方法的线程,会暂时让出指定时间的执行权,也就是在这期间,不参与cpu的调度,但是调用了sleep方法的线程并不会释放监视器资源,比如锁还是持有不让出的,只是会进入阻塞状态。指定的睡眠时间到了后,线程就处于就绪状态,参与cpu调度,获取到cpu资源后就可以继续执行了。
如果在睡眠期间,其他线程调用了该线程的sleep的interrupt方法,则会在sleep处抛出InterruptedException异常
private static final Lock lock = new ReentrantLock();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
lock.lock();
try {
System.out.println("1 is sleep before...");
lock.wait();
System.out.println("1 is sleep after...");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
});
Thread t2 = new Thread(() -> {
lock.lock();
try {
System.out.println("2 is sleep before...");
Thread.sleep(10000);
System.out.println("2 is sleep after...");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.notify();
lock.unlock();
}
});
t1.start();
t2.start();
}
执行结果如下
1 is sleep before...
1 is sleep after...
2 is sleep before...
2 is sleep after...
1.5.3 Yield
yield方法也为Thread类的静态方法,当一个线程执行了yield方法时,实际就是在暗示线程调度器当前线程请求让出自己的cpu使用,但是线程调度器可以无条件忽略这个暗示。我们知道操作系统是给每一个线程分配一个时间片来占用cpu的,正常情况下,当一个线程把分配给自己的时间片用完后,线程调度器才会进行下一轮的线程调度,而当线程调了yield方法,就是告诉线程调度器,我虽然时间片还有,但是剩余时间我不想用了,你可以进行下一轮线程调度了。
调用yield方法后,线程会让出cpu执行权,然后进入就绪状态,线程调度器会从就绪队列中获取一个优先级最高的线程,也有可能会调度到刚刚让出cpu执行权的那个线程。
一般很少使用这个方法,在调试或者测试时,这个方法或许可以帮助复现由于并发竞争条件导致的问题,并在设计并发控制时或许会有用途
1.5.4 wati、join、sleep、yield总结
wait、join、sleep三个方法都会使调用的线程进入阻塞状态,而yield只是让出cpu,线程转为就绪状态;join、sleep、yield都不会释放锁,wait会释放监视器资源;
2、 ThreadLocal
线程变量副本,每个线程内部维护了一个ThreadLocalMap,这个ThreadLocalMap是ThreadLocal的内部类,当创建线程的时候,这个map为null,在第一次调用ThreadLocal的set或get方法时,会创建一个map并赋值给当前线程的threadLocalMap变量,它类似于HashMap,源代码如下
//set 方法
public void set(T value) {
//获取当前线程
Thread t = Thread.currentThread();
//实际存储的数据结构类型
ThreadLocalMap map = getMap(t);
//如果存在map就直接set,没有则创建map并set
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
//getMap方法
ThreadLocalMap getMap(Thread t) {
//thred中维护了一个ThreadLocalMap
return t.threadLocals;
}
//createMap
void createMap(Thread t, T firstValue) {
//实例化一个新的ThreadLocalMap,并赋值给线程的成员变量threadLocals
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
set值的时候,首先要用当前线程获取到内部维护的map,然后再用当前ThreadLocal作为key存入。其ThreadLocalMap中维护了一个Entry数组,这个数组用于存放不同ThreadLocal对象的k-v
它有一个问题就是存入的值只能由当前线程获取到,如果在main函数中set进去值,在main函数创建了一个子线程,想要在子线程中获取ThreadLocal的值是无法做到的,这就要用到InheritableThreadLocal类了
3、 InheritableThreadLocal
InheritableThreadLocal继承ThreadLocal,在线程内部也维护了一个InheritableThreadLocal类型的成员变量,该类重写了三个方法
protected T childValue(T parentValue) {
return parentValue;
}
ThreadLocalMap getMap(Thread t) {
return t.inheritableThreadLocals;
}
void createMap(Thread t, T firstValue) {
t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
}
创建线程的代码如下:
public Thread(Runnable target) {
init(null, target, "Thread-" + nextThreadNum(), 0);
}
private void init(ThreadGroup g, Runnable target, String name, long stackSize) {
// 省略与本节内容无关的代码, 以下代码略有改动, 与jdk1.8稍有区别, 但逻辑一样
Thread parent = currentThread();
// 如果父线程的inheritableThreadLocals不为null, 则当前线成也要有inheritableThreadLocals
if (parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
}
如此一来,解决了ThreadLocal继承性问题
4、 多线程其他基础
4.1 理解并发与并行
并发是指在一段时间内,多个任务同时执行;并行是指在单位时间内同时执行。
首先说说并发。并发讲究的是在一个时间段内,实际上多个任务并不是同时进行的。上述介绍过了,线程的执行是需要获取CPU的,CPU时间轮转的方式让线程占用,每次都会给线程分一个时间片,一个CPU一个时间点只能执行一个任务,但是因为CPU的运行速度是非常快的,在轮询执行的一段时间里,会让你觉得CPU在同时执行多个任务。
并行,讲究的是单位时间内,这才是真正意义上的同时运行,这是在多CPU的时代才会发生的,在单CPU时代,多个任务就是并发执行,这个时代,多线程编程的意义并不大,因为本身就一个CPU干活,采用多线程编程,不仅不会加快执行速度,反而会因线程上下文切换消耗资源与时间而大大拉低执行速度。
下图为并发,多个任务在一个CPU轮换执行
下图为并行,多CPU时代,两个任务都在各自的CPU执行,实现了真正的并行
4.2 Java中线程安全问题
谈线程安全问题之前,必须要先说下共享资源。所谓共享资源,就是说该资源被多个线程持有,或者说,多个线程都可访问该资源。
线程安全问题是指当多个线程同时读写一个共享资源并且没有采用任何同步措施,而导致出现脏数据或者其他不可预见结果的问题。是不是多个线程共享了资源就一定会出现线程安全问题呢?实际上,若多个线程只是对共享资源进行读操作,并不会出现线程安全问题,至少有一个线程进行了写操作,才会出现线程安全问题,最常见的线程安全问题就是计数器,由于计算操作的步骤是:取值——计算——保存这三个步骤,所以可能会导致计算不准确
当线程A递增后还未写回主内存,这个时候线程B取了值,取得值是A未递增的值,所以两个线程执行结束本该递增2,结果却递增了1
4.3 Java中共享变量的内存可见性问题
谈内存可见性问题前,我们先来看看在多线程下处理共享变量时JVM的内存模型,如下所示:
Java内存模型规定,将所有的变量都存在主内存中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间,或者叫工作内存,线程读写变量时,操作的是自己工作内存中的变量。Java内存模型是一个抽象的概念,下图为实际实现中,线程的工作内存:
途中所示的是一个双核CPU,每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器是执行算术逻辑运算的。每个核都有自己的一级缓存,在有些架构里,还有一个所有CPU都共享的二级缓存。那么Java内存模型里面的工作内存就对应着一级缓存、二级缓存或者CPU寄存器
当一个线程操作共享变量时,会先将共享变量赋值到自己的工作内存,然后对工作内存中的变量进行处理,处理完后将变量值更新到主内存。看下面的一种情况
假如线程A和线程B同时处理一个共享变量,我们就使用上图的CPU架构,假设A和B用的不同的CPU内核执行,并且两级缓存都为空,那么这个时候由于缓存的存在,就会导致内存不可见的问题,具体看下面分析:
- 线程A首先获取共享变量X,由于两级缓存都没有命中,所以会加载主内存的值,假设X为0。然后把X=0的值缓存到到两级缓存,线程A修改X的值为1,然后将其写入两级缓存,并且刷新到主内存。线程A操作完线程A所在的CPU两级缓存的X都为1,主内存X也为1。
- 线程B获取X的值,两级缓存没有命中,加载主内存X的值,此时X=1,刷新到两级缓存,此时B线程修改X值为2,并写入两级缓存,最后刷新到主内存,此时线程B所在的CPU两级缓存都为2,主内存也为2。到现在还没有任何问题。
- 线程A这次又需要修改X的值,在一级缓存就命中,并且X=1,到这里就有问题了,明明线程B已经将X修改为2了,但线程A取到的X的值仍为1。这就是共享变量的内存不可见问题,也就是线程B写入的值对线程A不可见。
那么如何解决这个问题呢?使用Java中的volatile关键字就可以解决这个问题。
4.4 volatile解决内存不可见问题
在之前介绍synchornized底层原理的时候提到过内存不可见问题以及它是如何解决这个问题的原理,这里再简单提一下。
synchornized是在进入同步代码之前,将同步代码中用到的共享变量在缓存中清除,这样在用到的时候直接从主存拉取,在退出同步代码之前,将对共享资源的操作更新到主内存,这样就保证了可见性。但synchornized是会导致线程上下文切换的,这就意味着CPU需要从用户态切换至内核态,先阻塞线程,再唤醒另外一个线程,然后再从内核态切换到用户态,这是相当慢的,那如果我们只是为了解决内存不可见的问题,就用synchornized就有点不划算了,所以Java给我们提供了volatile关键字
volatile关键字用于修饰方法,可以说是synchornized的轻量版,只保证可见性与有序性,并不能保证同步性。当一个变量被声明为volatile时,线程在写入变量时并不会把它写到缓存或者是寄存器,而是直接把它刷新到主内存,同样在读它的时候也不会从缓存读,而是直接从内存拉取,其实本质上与synchornized解决内存不可见一样,只不过volatile可以避免线程上下文切换带来的开销。
那么,一般在什么时候才使用volatile关键字呢?
- 写入变量不依赖变量当前值时。因为如果依赖当前值,将是获取——计算——写入,三步操作,这三步操作不是原子性的,而volatile不保证原子性,会出现线程安全问题
- 读写变量时没有加锁。因为锁本身已经保证了内存可见性,这时候不需要把变量声明成volatile的。
以上是关于多线程必备基础的主要内容,如果未能解决你的问题,请参考以下文章