多线程

线程和进程

进程是程序的⼀次执⾏过程,是系统运⾏程序的基本单位,因此进程是动态的。系统运⾏⼀个程序即是⼀个进程从创建,运⾏到消亡的过程。

在 Java 中,当我们启动 main 函数时其实就是启动了⼀个 JVM 的进程,⽽ main 函数所在的线程就是这个进程中的⼀个线程,也称主线程。

线程 但线程是⼀个⽐进程更⼩的执⾏单位。⼀个进程在其执⾏的过程中可以产⽣多个线程。与进程不同的是同类的多个线程共享进程的堆和⽅法区资源,但每个线程有⾃⼰的程序计数器、虚拟机栈和本地⽅法栈,所以系统在产⽣⼀个线程,或是在各个线程之间作切换⼯作时,负担要⽐进程⼩得多,也正因为如此,线程也被称为轻量级进程。

请简要描述线程与进程的关系,区别及优缺点

image-20230329233406321

⼀个进程中可以有多个线程,多个线程共享进程的堆和⽅法区 (JDK1.8 之后的元空间)资源,但是每个线程有⾃⼰的程序计数器、虚拟机栈 和 本地⽅法栈。

总结 :线程 是 进程 划分成的更⼩的运⾏单位。线程和进程最⼤的不同在于基本上各进程是独⽴的,⽽各线程则不⼀定,因为同⼀进程中的线程极有可能会相互影响。线程执⾏开销⼩,但不利于资源的管理和保护;⽽进程正相反

程序计数器为什么是私有的?

程序计数器主要有下⾯两个作⽤:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从⽽实现代码的流程控制,如:顺序执⾏、选择、循环、异常处理。

  2. 多线程的情况下,程序计数器⽤于记录当前线程执⾏的位置,从⽽当线程被切换回来的时候能够知道该线程上次运⾏到哪⼉了。

需要注意的是,如果执⾏的是 native ⽅法,那么程序计数器记录的是 undefined 地址,只有执⾏的是 Java 代码时程序计数器记录的才是下⼀条指令的地址。

所以,程序计数器私有主要是为了线程切换后能恢复到正确的执⾏位置

XXX为什么时私有的?

  • 为了运行时不被其他线程干扰
⼀句话简单了解堆和⽅法区

堆和⽅法区是所有线程共享的资源,其中堆是进程中最⼤的⼀块内存,主要⽤于存放新创建的对象 (所有对象都在这⾥分配内存),⽅法区主要⽤于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

并发和并行的区别

关键点 再单个时间点是否能同时

并发(Concurrent): 同⼀时间段,多个任务都在执⾏ (单位时间内不⼀定同时执⾏);(主要指CPU线程之间轮换)
并⾏(Parallel):同一时间点,多个任务同时执⾏ (多个线程同时工作,不用轮换 多核CPU)。

为什么要使⽤多线程呢?

从计算机底层来说: 线程可以⽐作是轻量级的进程,是程序执⾏的最⼩单位,线程间的切换和调度的成本远远⼩于进程。另外,多核 CPU 时代意味着多个线程可以同时运⾏,这减少了线程上下⽂切换的开销。
从当代互联⽹发展趋势来说: 现在的系统动不动就要求百万级甚⾄千万级的并发量,⽽多线程并发编程正是开发⾼并发系统的基础,利⽤好多线程机制可以⼤⼤提⾼系统整体的并发能⼒以及性能

再深⼊到计算机底层来探讨:

单核时代: 在单核时代多线程主要是为了提⾼ CPU 和 IO 设备的综合利⽤率。举个例⼦:当只有⼀个线程的时候会导致 CPU 计算时,IO 设备空闲;进⾏ IO 操作时,CPU 空闲。我们可以简单地说这两者的利⽤率⽬前都是 50%左右。但是当有两个线程的时候就不⼀样了,当⼀个线程执⾏ CPU 计算时,另外⼀个线程可以进⾏ IO 操作,这样两个的利⽤率就可以在理想情况下达到 100%了。

多核时代 多核时代多线程主要是为了提⾼ CPU 利⽤率。举个例⼦:假如我们要计算⼀个复杂的任务,我们只⽤⼀个线程的话,CPU 只会⼀个 CPU 核⼼被利⽤到,⽽创建多个线程就可以让多个 CPU 核⼼被利⽤到,这样就提⾼了 CPU 的利⽤率。

多线程常见问题

并发编程的⽬的就是为了能提⾼程序的执⾏效率提⾼程序运⾏速度,但是并发编程并不总是能提⾼程序运⾏速度的,⽽且并发编程可能会遇到很多问题,⽐如:内存泄漏上下⽂切换死锁

Q: 简述线程、程序、进程的基本概念。以及他们之间关系是什么?

程序是含有指令和数据的文件,被存储在磁盘或其他数据存储设备中,也就是说程序是静态的代码。

进程是程序的⼀次执⾏过程,是系统运⾏程序的基本单位,系统运⾏⼀个程序即是⼀个进程从创建,运⾏到消亡的过程。

线程是进程划分成的更⼩的运⾏单位,线程是处理器任务调度和执行的基本单位。线程和进程最⼤的不同在于基本上各进程是独⽴的,⽽各线程则不⼀定,因为同⼀进程中的线程极有可能会相互影响。

Q: java线程的状态状态

一种是将其分为6种一种是分为5种

6种(主要是从java代码的角度来进行划分。)

img

在这里插入图片描述

  1. 新建状态(NEW) :

    使用new关键字创建一个thread对象,刚刚创建出的这个线程就处于新建状态。在这个状态的线程没有与操作系真正的线程产生关联,仅仅是一个java对象。

  2. 可运行(RUNABLE):

    正在进行运行的线程,只有处于可运行状态的线程才会得到cpu资源。

  3. 阻塞(BLOCKED) :

    在可运行阶段争抢锁失败的线程就会从可运行—->阻塞

  4. 等待(WAITING) :

    可运行状态争抢锁成功,但是资源不满足,主动放弃锁(调用wait()方法)。条件满足后再恢复可运行状态(调用notiy()方法)

  5. 有时限等待(TIMED WAITING):

    类似于等待,不过区别在于有一个等待的时间,到达等待时间后或者调用notiy(),都能恢复为可运行状态。

    有两种方式可以进入有时限等待:wait(Long)和sleep(Long)

  6. 终结 (TERMINATED)

    代码全部执行完毕后,会进入到终结状态,释放所有的资源。

5种 :划分依据:从操作系统层面划分

划分依据:从操作系统层面划分

img

  1. 新建

    类似于六种,刚刚创建出的这个线程就处于新建状态。

  2. 就绪

    线程分到CPU时间运行代码,但是还没有运行。

  3. 运行

    线程分到CPU时间运行代码,并且正在运行。

  4. 阻塞

    线程暂时没有分到时间运行代码,就会进入阻塞状态,包括以下四种情况:

    • a. IO阻塞:不需要cpu资源(磁盘读写,网络读写)

    • b. BLOCKED

    • c. WAITING

    • d. TIMED_WAITING

  5. 终结

    类似于代码全部执行完毕后,会进入到终结状态,释放所有的资源。

什么是上下⽂切换?

多线程编程中⼀般线程的个数都⼤于 CPU 核⼼的个数,⽽⼀个 CPU 核⼼在任意时刻只能被⼀个线程使⽤,为了让这些线程都能得到有效执⾏,CPU 采取的策略是为每个线程分配时间⽚并轮转的形式。当⼀个线程的时间⽚⽤完的时候就会重新处于就绪状态让给其他线程使⽤,这个过程就属于⼀次上下⽂切换。

概括来说就是:当前任务在执⾏完 CPU 时间⽚切换到另⼀个任务之前会先保存⾃⼰的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是⼀次上下⽂切换。

Linux 相⽐与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有⼀项就是,其上下⽂切换和模式切换的时间消耗⾮常少。

Q: 什么是线程死锁?如何避免死锁?

死锁面试题(什么是死锁,产生死锁的原因及必要条件)

线程死锁描述的是这样⼀种情况:

简单来说就是等待一个不可能被释放的锁,多个线程同时被阻塞,它们中的⼀个或者全部都在等待某个资源被释放。由于线程被⽆限期地阻塞,因此程序不可能正常终⽌。

image-20230330091752815

以上这个情况(上图)满足死锁产生的四个必要条件

死锁产生的四个必要条件
  1. 互斥条件:该资源任意⼀个时刻只由⼀个线程占⽤。
  2. 请求与保持条件:⼀个进程因请求资源⽽阻塞时,对已获得的资源保持不放
  3. 不剥夺条件:线程已获得的资源在末使⽤完之前不能被其他线程强⾏剥夺,只有⾃⼰使⽤完毕后才释放资源
  4. 循环等待条件:若⼲进程之间形成⼀种头尾相接的循环等待资源关系。

避免死锁的就是把上述必要条件破坏的过程

  1. 破坏互斥条件 :这个条件我们没有办法破坏,因为我们⽤锁本来就是想让他们互斥的(临界资源需要互斥访问)。

  2. 破坏请求与保持条件 :⼀次性申请所有的资源。(其实不太容易 )

  3. 破坏不剥夺条件 :占⽤部分资源的线程进⼀步申请其他资源时,如果申请不到,可以主动释放它占有的资源。(有点像事务)

  4. 破坏循环等待条件 :靠按序申请资源来预防。按某⼀顺序申请资源(如银行家算法),释放资源则反序释放。破坏循环等待条件。

  • 就我们能做的就是:1、以确定的顺序获得锁(银行家算法)(第四点)2、超时放弃(boolean tryLock 设置等待时间)因此线程可以在获取锁超时以后,主动释放之前已经获得的所有的锁 (第三点)
检测死锁
  1. 首先为每个进程和每个资源指定一个唯一的号码;
  2. 然后建立资源分配表和进程等待表。
解除死锁:

当发现有进程死锁后,便应立即把它从死锁状态中解脱出来,常采用的方法有:

剥夺资源:从其它进程剥夺足够数量的资源给死锁进程,以解除死锁状态;
撤消进程:可以直接撤消死锁进程或撤消代价最小的进程,直至有足够的资源可用,死锁状态.消除为止;所谓代价是指优先级、运行代价、进程的重要性和价值等。

1、Jstack命令

jstack是java虚拟机自带的一种堆栈跟踪工具。jstack用于打印出给定的java进程ID或core file或远程调试服务的Java堆栈信息。 Jstack工具可以用于生成java虚拟机当前时刻的线程快照。线程快照是当前java虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等。 线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做什么事情,或者等待什么资源。

2、JConsole工具

Jconsole是JDK自带的监控工具,在JDK/bin目录下可以找到。它用于连接正在运行的本地或者远程的JVM,对运行在Java应用程序的资源消耗和性能进行监控,并画出大量的图表,提供强大的可视化界面。而且本身占用的服务器内存很小,甚至可以说几乎不消耗。

说说 sleep() ⽅法和 wait() ⽅法区别和共同点?

  • 两者最主要的区别在于: sleep() ⽅法没有释放锁,⽽ wait() ⽅法释放了锁 。
  • 两者都可以暂停线程的执⾏。
    • wait() 通常被⽤于线程间交互/通信, sleep() 通常被⽤于暂停执⾏。
    • wait() ⽅法被调⽤后,线程不会⾃动苏醒,需要别的线程调⽤同⼀个对象上的 notify() 或
      者 notifyAll() ⽅法。 sleep() ⽅法执⾏完成后,线程会⾃动苏醒。或者可以使⽤ wait(long
      timeout) 超时后线程会⾃动苏醒。

为什么我们调⽤ start() ⽅法时会执⾏ run() ⽅法,为什么我们不能直接调⽤ run() ⽅法?

调⽤ start() ⽅法⽅可启动线程并使线程进⼊就绪状态,直接执⾏ run() ⽅法的话不会以多线程的⽅式执⾏。

  • 直接使用run()会把 run()⽅法当成⼀个 main 线程下的普通⽅法去执⾏,并不会在某个线程中执⾏它,所以这并不是多线

    程⼯作。

  • 而,new ⼀个 Thread,线程进⼊了新建状态。调⽤ start() ⽅法,会启动⼀个线程并使线程进⼊了就绪状态,当分配到时间⽚后就可以开始运⾏了。

⾃⼰对于 synchronized 关键字的了解

synchronized 关键字解决的是多个线程之间访问资源的同步性, synchronized 关键字可以保证被它修饰的⽅法或者代码块在任意时刻只能有⼀个线程执⾏。

另外,在 Java 早期版本中, synchronized 属于 重量级锁,效率低下。

监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原⽣线程之上的。如果要挂起或者唤醒⼀个线程,都需要操作系统帮忙完成,⽽操作系统实现线程之间的切换时需要从⽤户态转换到内核态,这个状态之间的转换需要相对⽐较⻓的时间,时间成本相对较⾼。

是在 Java 6 之后 Java 官⽅对从 JVM 层⾯对 synchronized ᫾⼤优化,所以现在的synchronized 锁效率也优化得很不错了。JDK1.6 对锁的实现引⼊了⼤量的优化,如⾃旋锁、适应性⾃旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

synchronized 关键字使用:

synchronized 关键字最主要的三种使⽤⽅式:

1.修饰实例⽅法

2.修饰静态⽅法

3.修饰代码块(实例方法中的代码块,静待代码块)

总结

  • synchronized( .class) 表示进⼊同步代码前要获得 当前 class 的锁,synchronized 关键字加到 static 静态⽅法和synchronized(class) 代码块上都是是给 Class类上锁。上一份代码当锁对象是CodeBlock的实例对象时并发度更大一些,因为当锁对象是实例对象的时候,只有实例对象内部是不能够并发的,实例之间是可以并发的。但是当锁对象是CodeBlock.class的时候,实例对象之间时不能够并发的,因为这个时候的锁对象是一个类。
  • synchronized 关键字加到实例⽅法上是给对象实例上锁。

  • 尽量不要使⽤ synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能!

如:

双重校验锁实现对象单例(线程安全)
public class Singleton {
    private volatile static Singleton uniqueInstance;
    private Singleton() {
    }
    public static Singleton getUniqueInstance() {
        //先判断对象是否已经实例过,没有实例化过才进⼊加锁代码
        if (uniqueInstance == null) {
            //类对象加锁
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

另外,需要注意 uniqueInstance 采⽤ volatile 关键字(防止指令重排)修饰也是很有必要。

volatile是Java虚拟机提供的轻量级同步机制

  • 保证可见性
  • 不保证原子性
  • 禁止指令重排(保证有序性)

因为 这里面uniqueInstance = new Singleton(); 这段代码其实是分为三步执⾏:

  1. 为 uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 将 uniqueInstance 指向分配的内存地址

由于 JVM 具有指令重排的特性,执⾏顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致⼀个线程获得还没有初始化的实例。例如,线程 T1 执⾏了 1 和 3,此时 T2 调⽤ getUniqueInstance () 后发现 uniqueInstance 不为空,因此返回uniqueInstance ,但此时 uniqueInstance 还未被初始化。

构造⽅法可以使⽤ synchronized 关键字修饰么?

先说结论:构造⽅法不能使⽤ synchronized 关键字修饰。
构造⽅法本身就属于线程安全的,不存在同步的构造⽅法⼀说。

Synchronized关键字底层

synchronized 同步语句块

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("synchronized 代码块");
        }
    }
}

实现使⽤的是 monitorenter 和 monitorexit 指令,其中monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指明同步代码块的结束位置。

在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitor实现的。每个对象中都内置了⼀个 ObjectMonitor 对象。另外, wait/notify 等⽅法也依赖于 monitor 对象,这就是为什么只有在同步的块或者⽅法中才能调⽤ wait/notify 等⽅法,否则会抛出 java.lang.IllegalMonitorStateException 的异常的原因。

当执⾏ monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。

在执⾏ monitorenter 时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。
在执⾏ monitorexit 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外⼀个线程释放为⽌。

锁对象的建议

在前面的代码当中我们分别使用了实例对象和类的class对象作为锁对象,事实上你可以使用任何对象作为锁对象,但是不推荐使用字符串和基本类型的包装类作为锁对象,这是因为字符串对象和基本类型的包装对象会有缓存的问题。字符串有字符串常量池,整数有小整数池。因此在使用这些对象的时候他们可能最终都指向同一个对象,因为指向的都是同一个对象,线程获得锁对象的难度就会增加,程序的并发度就会降低。

synchronized 修饰⽅法的的情况

public class SynchronizedDemo2 {
    public synchronized void method() {
        System.out.println("synchronized ⽅法");
    }
}

synchronized 修饰的⽅法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是ACC_SYNCHRONIZED 标识,该标识指明了该⽅法是⼀个同步⽅法。JVM 通过该ACC_SYNCHRONIZED 访问标志来辨别⼀个⽅法是否声明为同步⽅法,从⽽执⾏相应的同步调⽤

总结:

synchronized 同步语句块的实现使⽤的是 monitorenter 和 monitorexit 指令,其中monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指明同步代码块的结束位置。
synchronized 修饰的⽅法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是ACC_SYNCHRONIZED 标识,该标识指明了该⽅法是⼀个同步⽅法。不过两者的本质都是对对象监视器 monitor 的获取。

为什么要弄⼀个 CPU ⾼速缓存(CPU cache)呢?

类⽐我们开发⽹站后台系统使⽤的缓存(⽐如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。 CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。
我们甚⾄可以把 内存可以看作外存的⾼速缓存,程序运⾏的时候我们把外存的数据复制到内存,由于内存的处理速度远远⾼于外存,这样提⾼了处理速度。

image-20230330102034896

先复制⼀份数据到 CPU Cache 中,当 CPU 需要⽤到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 内存缓存不⼀致性的问题 !⽐如我执⾏⼀个 i++操作的话,如果两个线程同时执⾏的话,假设两个线程从 CPUCache 中读取的 i=1,两个线程做了 1++运算完之后再写回 Main Memory 之后 i=2,⽽正确结果应该是 i=3。CPU 为了解决内存缓存不⼀致性问题可以通过制定缓存⼀致协议或者其他⼿段来解决

讲⼀下 JMM(Java 内存模型)

在 JDK1.2 之前,Java 的内存模型实现总是从主存(即共享内存)读取变量,是不需要进⾏特别的注意的。⽽在当前的 Java 内存模型下,线程可以把变量保存本地内存(⽐如机器的寄存器)中,⽽不是直接在主存中进⾏读写。这就可能造成⼀个线程在主存中修改了⼀个变量的值,⽽另外⼀个线程还继续使⽤它在寄存器中的变量值的拷⻉,造成数据的不⼀致.(可以理解为 JIT 对热点数据的优化)

解决,对相应的变量上怎加voletile关键字,,这就指示 JVM,这个变量是共享且不稳定的,每次使⽤它都到共享内存中进⾏读取

所以记住:, volatile 关键字 除了防⽌ JVM 的指令重排(如双检索单例模式,在静态成员变量(单例)前面要加 volatile关键字,避免这个变量创建过程中的指令重排) ,还有⼀个重要的作⽤就是保证变量的可⻅性(避免JIT对热点代码进行优化时将变量放到CPU缓存上导致与内存中的变量值不一致)。

Q: ThreadLocal

详细见引用类型与ThreadLocal这篇文章

  • 线程并发;
  • 传递数据; 通过ThreadLoacal 在同一线程,不同组件中传递公共变量(有点像javaweb)
  • 线程隔离

通常情况下,我们创建的变量是可以被任何⼀个线程访问并修改的。如果想实现每⼀个线程都有⾃⼰的专属本地变量该如何解决呢? JDK 中提供的 ThreadLocal 类正是为了解决这样的问题。ThreadLocal 类主要解决的就是让每个线程绑定⾃⼰的值,可以将 ThreadLocal 类形象的⽐喻成存放数据的盒⼦,盒⼦中可以存储每个线程的私有数据。

如果你创建了⼀个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是 ThreadLocal 变量名的由来。他们可以使⽤ get 和 set ⽅法来获取默认值或将其值更改为当前线程所存的副本的值,从⽽避免了线程安全问题。

底层:

从 Thread 类源代码⼊⼿。

Thread 类中有 名为 threadLocals 和 ⼀个 inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量

我们可以把ThreadLocalMap 理解为 ThreadLocal 类实现的定制化的 HashMap 。。默认情况下这两个变量都是 null,只有当前线程调⽤ ThreadLocal 类的 set 或 get ⽅法时才创建它们,实际上调⽤这两个⽅法的时候,我们调⽤的是 ThreadLocalMap 类对应的 get() 、 set() ⽅法

通过上⾯这些内容,我们⾜以通过猜测得出结论:最终的变量是放在了当前线程的ThreadLocalMap 中,并不是存在 ThreadLocal 上, ThreadLocal 可以理解为只是 ThreadLocalMap 的封装,传递了变量值。 ThrealLocal 类中可以通过 Thread.currentThread()
获取到当前线程对象后,直接通过 getMap(Thread t) 可以访问到该线程的 ThreadLocalMap 对象。

ThreadLocal 内部维护的是⼀个类似 Map 的 ThreadLocalMap 数据结构, key 为当前对象的 Thread 对象,值为 Object 对象。