JUC多线程面试典籍30+ | 大别山码将

JUC

什么是线程和进程?

进程:进程是程序的⼀次执⾏过程,是系统运⾏程序的基本单位,因此进程是动态的。系统运⾏⼀个程序即是⼀个进程从创建,运⾏到消亡的过程。在 Java 中,当我们启动 main 函数时其实就是启动了⼀个 JVM 的进程,⽽ main 函数所在的线程就是这个进程中的⼀个线程,也称主线程。

  • 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至CPU,数据加载至内存,在指令进行运行过程中还需要用到磁盘,网络等设备。进程就是用来加载指令,管理内存,管理IO的
  • 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程
  • 进程就可以视为程序的一个实例,大部分程序可以运行多个实例进程(例如网易云音乐,360安全卫士等)

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

⼀个 Java 程序的运⾏是 main 线程和多个其他线程同时运⾏

线程执⾏开销⼩,但不利于资源的管理和保护;⽽进程正相反

  • 一个进程可以分为一到多个线程
  • 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给CPU执行
  • Java中,线程作为最小调度单位,进程作为资源分配的最小单位,在windows中进程是不活动的,只是作为线程的容器

二者对比

  • 线程 是 进程 划分成的更⼩的运⾏单位。⼀个进程中可以有多个线程

  • 进程基本上是相互独立的,⽽各线程则不⼀定,因为同⼀进程中的线程极有可能会相互影响(比如:多个线程共享进程的⽅法区 (JDK1.8之后的元空间)资源,但是每个线程有⾃⼰的程序计数器虚拟机栈本地⽅法栈

  • **线程执⾏开销⼩,但不利于资源的管理和保护;**⽽进程正相反

  • 进程间通信较为复杂。线程通信相对简单,因为他们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量

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

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

    1. 字节码解释器通过改变程序计数器来依次读取指令,从⽽实现代码的流程控制,如:顺序执⾏、选择、循环、异常处理。
    1. 在多线程的情况下,程序计数器⽤于记录当前线程执⾏的位置,从⽽当线程被切换回来的时候能够知道该线程上次运⾏到哪⼉了。

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

虚拟机栈和本地⽅法栈为什么是私有的?

虚拟机栈: 每个 Java ⽅法在执⾏的同时会创建⼀个栈帧⽤于存储局部变量表、操作数栈、常量池引⽤等信息。从⽅法调⽤直⾄执⾏完成的过程,就对应着⼀个栈帧在 Java 虚拟机栈中⼊栈和出栈的过程。

本地⽅法栈: 和虚拟机栈所发挥的作⽤⾮常相似,区别是: 虚拟机栈为虚拟机执⾏ Java⽅法 (也就是字节码)服务,⽽本地⽅法栈则为虚拟机使⽤到的Native⽅法服务。 在HotSpot 虚拟机中和 Java 虚拟机栈合⼆为⼀。

所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地⽅法栈是线程私有的。

并发与并行的区别

并发: 同⼀时间段,多个任务都在执⾏ (单位时间内不⼀定同时执⾏);

单核CPU下,线程实际还是串行执行的,操作系统中有一个组件叫做任务调度器,将cpu的时间片分给不同的线程使用,只是由于cpu在线程间(时间片很短)的切换非常快,人类感觉是同时运行的。总结为一句话就是:微观串行,宏观并行。一般将这种线程轮流使用CPU的做法称为并发

并⾏: 单位时间内,多个任务同时执⾏。

多核cpu下,每个核core都可以调度运行线程,这时候线程是可以并行的

例子:

  • 家庭主妇做饭,打扫卫生,带孩子,她一个人轮流交替做这么多事,这时就是并发
  • 家庭主妇雇了三个保姆,一个专做饭,一个专打扫卫生,一个专带孩子,互不干扰,这就是并行

说说为什么要使用多线程呢?使用多线程会有什么问题?

先从总体上来说:

从计算机底层来说: 线程可以⽐作是轻量级的进程,是程序执⾏的最⼩单位,线程间的切换和调度的成本远远⼩于进程。另外,多核 CPU 时代意味着多个线程可以同时运⾏,这减少了线程上下⽂切换的开销。

从当代互联⽹发展趋势来说: 现在的系统动不动就要求百万级甚⾄千万级的并发量,⽽多线程并发编程正是开发⾼并发系统的基础,利⽤好多线程机制可以⼤⼤提⾼系统整体的并发能⼒以及性能

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

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

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

线程的生命周期和状态

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CZ6VGeNe-1632497413391)(C:\Users\LENOVO-LX\AppData\Roaming\Typora\typora-user-images\image-20210908091524857.png)]

由上图可以看出:线程创建之后它将处于 NEW新建) 状态,调⽤ start() ⽅法后开始运⾏,线程这时候处于 READY可运⾏)状态。可运⾏状态的线程获得了 CPU 时间⽚(timeslice)后就处于 RUNNING运⾏) 状态。

当线程执⾏ wait() ⽅法之后,线程进⼊ WAITING等待) 状态。进⼊等待状态的线程需要依靠其他线程的通知才能够返回到运⾏状态,⽽ TIME_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,(⽐如通过 sleep⽅法或 wait⽅法可以将Java 线程置于 TIMED WAITING 超时状态。)当超时时间到达后 Java 线程将会返回到 RUNNABLE运行 状态。

当线程调⽤同步⽅法时,在没有获取到锁的情况下,线程将会进⼊到 BLOCKED阻塞)状态。线程在执⾏ Runnable 的 run() ⽅法之后将会进⼊到 TERMINATED(终⽌) 状态。

Java 中实现多线程的四种方式(创建多线程的四种方式)?

一、继承 Thread 类创建线程类

(1)定义 Thread 类的子类,并重写该类的 run 方法,该 run 方法的方 法体就代表了线程要完成的任务。因此把 run()方法称为执行体。
(2)创建 Thread 子类的实例,即创建了线程对象。
(3)调用线程对象的 start()方法来启动该线程

二、通过 Runnable 接口创建线程类

(1)定义 Runnable 接口的实现类,并重写该接口的 run()方法,该 run() 方法的方法体同样是该线程的线程执行体。
(2)创建 Runnable 实现类的实例,并依此实例作为 Thread 的 target 来创建 Thread 对象,该 Thread 对象才是真正的线程对象。
(3)调用线程对象的 start()方法来启动该线程。

三、通过 Callable 和 Future 创建线程

(1)创建 Callable 接口的实现类,并实现 call()方法,该 call()方法将作 为线程执行体,并且有返回值
(2)创建 Callable 实现类的实例,使用 FutureTask 类来包装 Callable 对象,该 FutureTask 对象封装了该 Callable 对象的 call()方法的返回值。
(3)使用 FutureTask 对象作为 Thread 对象的 target 创建并启动新线 程。
(4)调用 FutureTask 对象的 get()方法来获得子线程执行结束后的返回 值。

四、通过线程池创建线程

利用线程池不用 new 就可以创建线程,线程可复用,利用 Executors 创 建线程池。

扩展1:Java 中 Runnable 和 Callable 有什么不同?

I. Callable 定义的方法是 call(),而 Runnable 定义的方法是 run()。
II. Callable 的 **call 方法可以有返回值,**而 Runnable 的 run 方法不能有 返回值。
III. Callable 的 **call 方法可抛出异常,**而 Runnable 的 run 方法不能抛出异常。

一个类可以同时继承 Thread 和实现 Runnable接口

public class Test extends Thread implements Runnable {
    public static void main(String[] args) {
        Thread t = new Thread(new Test());
        t.start();
    }
}

多线程同步问题?

在多线程的环境中,经常会遇到数据的共享问题,即当多个线程需要访问同 一资源时,他们需要以某种顺序来确保该资源在某一时刻只能被一个线程使用, 否则,程序的运行结果将会是不可预料的,在这种情况下,就必须对数据进行 同步。
     在 Java 中,提供了四种方式来实现同步互斥访问: synchronized 和 Lock 和 wait()/notify()/notifyAll()方法和 CAS。

一.synchronized

  1. 同步代码块
    synchronized 块写法:
    synchronized(object)
    {
    }
         表示线程在执行的时候会将 object 对象上锁。(注意这个对象可以是任意 类的对象,也可以使用 this 关键字或者是 class 对象)。
         可能一个方法中只有几行代码会涉及到线程同步问题,所以 synchronized 块 比 synchronized 方法更加细粒度地控制了多个线程的访问, 只有 synchronized 块中的内容不能同时被多个线程所访问,方法中的其他语句仍然 可以同时被多个线程所访问(包括 synchronized 块之前的和之后的)。
  2. 修饰非静态的方法
        当 synchronized 关键字修饰一个方法的时候,该方法叫做同步方法。
         Java 中的每个对象都有一个锁(lock),或者叫做监视器(monitor), 当一个线程访问某个对象的 synchronized 方法时,将该对象上锁,其他任何 线程都无法再去访问该对象的 synchronized 方法了(这里是指所有的同步方 法,而不仅仅是同一个方法),直到之前的那个线程执行方法完毕后(或者是 抛出了异常),才将该对象的锁释放掉,其他线程才有可能再去访问该对象的 synchronized 方法。
         注意这时候是给对象上锁,如果是不同的对象,则各个对象之间没有限制 关系。
         注意,如果一个对象有多个 synchronized 方法,某一时刻某个线程已经进入 到了某个 synchronized 方法,那么在该方法没有执行完毕前,其他线程是无法访 问该对象的任何 synchronized 方法的。
  3. 修饰静态的方法
        当一个 synchronized 关键字修饰的方法同时又被 static 修饰,之前说过, 非静态的同步方法会将对象上锁,但是静态方法不属于对象,而是属于类,它 会将这个方法所在的类的 Class 对象上锁。一个类不管生成多少个对象,它们 所对应的是同一个 Class 对象。
         因此,当线程分别访问同一个类的两个对象的两个 static,synchronized 方法时,它们的执行顺序也是顺序的,也就是说一个线程先去执行方法,执行 完毕后另一个线程才开始。
    结论:
         synchronized 方法是一种粗粒度的并发控制,某一时刻,只能有一个线 程执行该 synchronized 方法。**
         synchronized 块则是一种细粒度的并发控制,只会将块中的代码同步, 位于方法内,synchronized 块之外的其他代码是可以被多个线程同时访问到 的。**

二.Lock

使用 Lock 必须在 try-catch-finally 块中进行,并且将释放锁的操作放在 finally 块中进行,以保证锁一定被释放,防止死锁的发生。通常使用 Lock 来 进行同步的话,是以下面这种形式去使用的:

Lock lock = ...;
lock.lock();
try{
    //处理任务
}catch(Exception ex){
    
}finally{
    lock.unlock();//释放锁
}

Lock 和 synchronized 的区别和 Lock 的优势。你需要实现 一个高效的缓存,它允许多个用户读,但只允许一个用户写,以此 来保持它的完整性,你会怎样去实现它?
     1)Lock 是一个接口,而 synchronized 是 Java 中的关键字,
     2)synchronized 在发生异常时,会自动释放线程占有的锁,因此不会导 致死锁现象发生;而 Lock 在发生异常时,如果没有主动通过 unLock()去释放 锁,则很可能造成死锁现象,因此使用 Lock 时需要在 finally 块中释放锁;
     3)Lock 可以让等待锁的线程响应中断(可中断锁),而 synchronized 却不行,使用 synchronized 时,等待的线程会一直等待下去,不能够响应中 断(不可中断锁);
     4)通过 Lock 可以知道有没有成功获取锁(tryLock()方法:如果获取 了锁,则返回 true;否则返回 false,也就说这个方法无论如何都会立即返回。 在拿不到锁时不会一直在那等待。),而 synchronized 却无法办到。
     5)Lock 可以提高多个线程进行读操作的效率(读写锁)。
     6)Lock 可以实现公平锁,synchronized 不保证公平性。 在性能上来说,如果线程竞争资源不激烈时,两者的性能是差不多的,而 **当竞争资源非常激烈时(即有大量线程同时竞争),此时 Lock 的性能要远远优 于 synchronized。**所以说,在具体使用时要根据适当情况选择。

扩展1: volatile 和 synchronized 区别。

  1. volatile 是变量修饰符,而 synchronized 则作用于代码块或方法。
  2. volatile 不会对变量加锁,不会造成线程的阻塞;synchronized 会 对变量加锁,可能会造成线程的阻塞。
  3. volatile 仅能实现变量的修改可见性,并不能保证原子性;而 synchronized 则 可 以 保 证 变 量 的 修 改 可 见 性 和 原 子 性 。 (synchronized 有两个重要含义:它确保了一次只有一个线程可以执 行代码的受保护部分(互斥),而且它确保了一个线程更改的数据对于 其它线程是可见的(更改的可见性),在释放锁之前会将对变量的修改 刷新到主存中)。
  4. volatile 标记的变量不会被编译器优化,禁止指令重排序; synchronized 标记的变量可以被编译器优化。

扩展 2:什么场景下可以使用 volatile 替换 synchronized?

只需要保证共享资源的可见性的时候可以使用 volatile 替代, synchronized 保证可操作的原子性,一致性和可见性。

**三.wait()\notify()\notifyAll()**方法(Java 中怎样唤醒一个阻塞的线程?)。

在 Java 发展史上曾经使用 suspend()、resume()方法对于线程进行阻塞唤醒,但随之出 现很多问题,比较典型的还是死锁问题。
     解决方案可以使用以对象为目标的阻塞,即利用 Object 类的 wait()和 notify()方法实现 线程阻塞。
     首先,wait、notify 方法是针对对象的,调用任意对象的 wait()方法都将导致线程阻塞, 阻塞的同时也将释放该对象的锁,相应地,调用任意对象的 notify()方法则将随机解除该对 象阻塞的线程,但它需要重新获取改对象的锁,直到获取成功才能往下执行;其次,wait、 notify 方法必须在 synchronized 块或方法中被调用,并且要保证同步块或方法的锁对象与调 用 wait、notify 方法的对象是同一个,如此一来在调用 wait 之前当前线程就已经成功获取 某对象的锁,执行 wait 阻塞后当前线程就将之前获取的对象锁释放。

扩展 1: 为什么 wait(),notify(),notifyAll()等方法都定义在 Object 类中?

因为这三个方法都需要定义在同步代码块或同步方法中,这些方法的调用是依赖锁对 象的,而同步代码块或同步方法中的锁对象可以是任意对象,那么能被任意对象调用的方 法一定定义在 Object 类中。

扩展 2: notify()和 notifyAll()有什么区别?

notify()和 notifyAll()都是 Object 对象用于通知处在等待该对象的线程的方法。
void notify(): 唤醒一个正在等待该对象的线程,进入就绪队列等待 CPU 的调度。
void notifyAll(): 唤醒所有正在等待该对象的线程,进入就绪队列等待 CPU 的调度。
两者的最大区别在于:
notifyAll 使所有原来在该对象上等待被 notify 的线程统统退出 wait 的状态,变成等待该对 象上的锁,一旦该对象被解锁,他们就会去竞争。 notify 他只是选择一个 wait 状态线程进行通知,并使它获得该对象上的锁,但不惊动其他 同样在等待被该对象 notify 的线程们,当第一个线程运行完毕以后释放对象上的锁,此时 如果该对象没有再次使用 notify 语句,即便该对象已经空闲,其他 wait 状态等待的线程由 于没有得到该对象的通知,继续处在 wait 状态,直到这个对象发出一个 notify 或 notifyAll, 它们等待的是被 notify 或 notifyAll,而不是锁。

四.CAS

它是一种非阻塞的同步方式。具体参见上面的部分。

扩展一:同步锁的分类?

Synchronized 和 Lock 都是悲观锁。      乐观锁,CAS 同步原语,如原子类,非阻塞同步方式。

扩展二:锁的分类?

一种是代码层次上的,如 java 中的同步锁,可重入锁,公平锁,读写锁。      另外一种是数据库层次上的,比较典型的就是悲观锁和乐观锁,表锁,行锁,页锁。

扩展三:java 中的悲观锁和乐观锁?

悲观锁:悲观锁是认为肯定有其他线程来争夺资源,因此不管到底会不会发生争夺, 悲观锁总是会先去锁住资源,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放 锁。Synchronized 和 Lock 都是悲观锁
     乐观锁:每次不加锁,假设没有冲突去完成某项操作,如果因为冲突失败就重试,直 到成功为止。就是当去做某个修改或其他操作的时候它认为不会有其他线程来做同样的操 作(竞争),这是一种乐观的态度,通常是基于 CAS 原子指令来实现的。CAS 通常不会将 线程挂起,因此有时性能会好一些。乐观锁的一种实现方式——CAS。

多线程中锁的种类

  1. 可重入锁
        ReentrantLock 和 synchronized 都是可重入锁。
         如果当前线程已经获得了某个监视器对象所持有的锁,那么该线程在该方法 中调用另外一个同步方法也同样持有该锁。
    比如:
public sychrnozied void test() {
    xxxxxx;
    test2();
}
public sychronized void test2() {
    yyyyy;
}

在上面代码段中,执行 test 方法需要获得当前对象作为监视器的对象锁, 但方法中又调用了 test2 的同步方法。
     如果锁是具有可重入性的话,那么该线程在调用 test2 时并不需要再次获 得当前对象的锁,可以直接进入 test2 方法进行操作。
     可重入锁最大的作用是避免死锁。如果锁是不具有可重入性的话,那么该 线程在调用 test2 前会等待当前对象锁的释放,实际上该对象锁已被当前线程 所持有,不可能再次获得,那么线程在调用同步方法、含有锁的方法时就会产 生死锁。

  1. 可中断锁
        顾名思义,就是可以响应中断的锁。
         在 Java 中,synchronized 不是可中断锁,而 Lock 是可中断锁。 lockInterruptibly()的用法已经体现了 Lock 的可中断性。如果某一线程 A 正 在执行锁中的代码,另一线程 B 正在等待获取该锁,可能由于等待时间过长, 线程 B 不想等待了,想先处理其他事情,我们可以让它中断自己或者在别的线 程中断它,这种就是可中断锁。

    1. 公平锁
          在 Java 中,synchronized 就是非公平锁,它无法保证等待的线程获取锁 的顺序。而对于 ReentrantLock 和 ReentrantReadWriteLock,它默认情况 下是非公平锁,但是可以设置为公平锁。
           公平锁即尽量以请求锁的顺序来获取锁。比如同是有多个线程在等待一个 锁,当这个锁被释放时,等待时间最久的线程(最先请求的线程)会获得该锁, 这种就是公平锁。

    2. 读写锁
          正因为有了读写锁,才使得多个线程之间的读操作不会发生冲突。 ReadWriteLock 就是读写锁,它是一个接口,ReentrantReadWriteLock 实 现了这个接口。可以通过 readLock()获取读锁,通过 writeLock()获取写锁。

锁优化:

  1. 自旋锁
        为了让线程等待,让线程执行一个忙循环(自旋)。需要物理机器有一个以 上的处理器。自旋等待虽然避免了线程切换的开销,但它是要占用处理器时间 的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之自旋的 线程只会白白消耗处理器资源。自旋次数的默认值是 10 次,可以使用参数 -XX:PreBlockSpin 来更改。
         **自适应自旋锁:自旋的时间不再固定,**而是由前一次在同一个锁上的自旋 时间及锁的拥有者的状态来决定。
  2. 锁清除
        指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不 可能存在共享数据竞争的锁进行清除(逃逸分析技术:在堆上的所有数据都不会 逃逸出去被其它线程访问到,可以把它们当成栈上数据对待)。
  3. 锁粗化
        如果虚拟机探测到有一串零碎的操作都对同一个对象加锁,将会把加锁同 步的范围扩展到整个操作序列的外部。
  4. 轻量级锁
        在代码进入同步块时,如果此同步对象没有被锁定,虚拟机首先将在当前 线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储所对象目 前的 Mark Word 的拷贝。然后虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为执行 Lock Record 的指针。如果成功,那么这个线程就拥有了该 对象的锁。如果更新操作失败,虚拟机首先会检查对象的 Mark Word 是否指 向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,否则说 明这个对象已经被其它线程抢占。如果有两条以上的线程争用同一个锁,那轻 量级锁就不再有效,要膨胀为重量级锁。
         解锁过程:如果对象的 Mark Word 仍然指向着线程的锁记录,那就用 CAS 操作把对象当前的 Mark Word 和和线程中复制的 Displaced Mark Word 替 换回来,如果替换成功,整个过程就完成。如果失败,说明有其他线程尝试过 获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
         轻量级锁的依据:对于绝大部分的锁,在整个同步周期内都是不存在竞争 的。
         传统锁(重量级锁)使用操作系统互斥量来实现的。

HotSpot 虚拟机的对象的内存布局:对象头(Object Header)分为两部分信息吗,第 一部分(Mark Word)用于存储对象自身的运行时数据,另一个部分用于存储指向方法区 对象数据类型的指针,如果是数组的话,还会由一个额外的部分用于存储数组的长度。
32 位 HotSpot 虚拟机中对象未被锁定的状态下, Mark Word 的 32 个 Bits 空间中 25 位 用于存储对象哈希码,4 位存储对象分代年龄,2 位存储锁标志位,1 位固定为 0。
HotSpot 虚拟机对象头 Mark Word

|--------------------------------------------------|
|存储内容                        |标志位 |状态        |
| -------------------------------------------------|
|对象哈希码、对象分代年龄           |01   |未锁定       |
|--------------------------------------------------|
|指向锁记录的指针                  |00   |轻量级锁定   |
|--------------------------------------------------|
|指向重量级锁的指针                |10   |膨胀(重量级锁)|
|--------------------------------------------------|
|空,不记录信息                    |11   |GC 标记      |
|--------------------------------------------------|
|偏向线程 ID,偏向时间戳、对象分代年龄 |01   |可偏向      |
|--------------------------------------------------|
  1. 偏向锁 :目的是消除在无竞争情况下的同步原语,进一步提高程序的运行性能。锁 会偏向第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其它线 程获取,则持有锁的线程将永远不需要再进行同步。
        当锁第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为 01, 同时使用 CAS 操作把获取到这个锁的线程的 ID 记录在对象的 Mark Word 之 中,如果成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,都可 以不进行任何同步操作。
         当有另一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象 目前是否处于被锁定的状态,撤销偏向后恢复到未锁定或轻量级锁定状态。

实现线程之间的通信?

当线程间是可以共享资源时,线程间通信是协调它们的重要的手段。

  1. Object 类中 wait()\notify()\notifyAll()方法。
  2. 用 Condition 接口。
        Condition 是被绑定到 Lock 上的,要创建一个 Lock 的 Condition 对 象必须用 newCondition()方法。在一个 Lock 对象里面可以创建多个 Condition 对象,线程可以注册在指定的 Condition 对象中,从而可以有 选择性地进行线程通知,在线程调度上更加灵活。
         在 Condition 中,用 await()替换 wait(),用 signal()替换 notify(), 用 signalAll()替换 notifyAll(),传统线程的通信方式, Condition 都可以实现。 调用 Condition 对象中的方法时,需要被包含在 lock()和 unlock()之间。
  3. 管道实现线程间的通信。
        实现方式:一个线程发送数据到输出管道流,另一个线程从输入管道流中 读取数据
         基本流程:
         1)创建管道输出流 PipedOutputStream pos 和管道输入流 PipedInputStream pis。
         2)将 pos 和 pis 匹配,pos.connect(pis)。
         3)将 pos 赋给信息输入信息的线程,pis 赋给获取信息的线程,就可以实 现线程间的通讯了。
         缺点:**
         1)管道流只能在两个线程之间传递数据。**
         线程 consumer1 和 consumer2 同时从 pis 中 read 数据,当线程 producer 往管道流中写入一段数据(1,2,3,4,5,6)后,每一个时刻只有一个 线程能获取到数据,并不是两个线程都能获取到 producer 发送来的数据,因 此一个管道流只能用于两个线程间的通讯。
         2)管道流只能实现单向发送,如果要两个线程之间互通讯,则需要两个管道 流。
         线程 producer 通过管道流向线程 consumer 发送数据,如果线程 consumer 想给线程 producer 发送数据,则需要新建另一个管道流 pos1 和 pis1,将 pos1 赋给 consumer1,将 pis1 赋给 producer1。
  4. 使用 volatile 关键字
    见上面部分。

什么是上下文切换?

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

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

从这个角度来看,上下文切换有点像我们同时阅读几本书,在来回切换书本的同时我们需要记住每本书当前读到的页码。在程序中,上下文切换过程中的“页“页码”信息会一直保存到CPU的内存中,直到他们被再次使用。

上下⽂切换对系统来说意味着消耗⼤量的CPU 时间,可能是操作系统中时间消耗最⼤的操作。Linux 相⽐与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有⼀项就是,其上下⽂切换和模式切换的时间消耗⾮常少。

上下文切换消耗cpu时间如何优化?

竞争锁优化:多线程对锁资源的竞争会引起上下文切换,锁竞争但值得线程阻塞越多,上下文切换就越频繁,系统的性能开销就越大。

减少锁的持有时间:锁的持有时间越长就意味着越多的线程在等待该竞争锁释放。优化方法:将一些与锁无关的代码移出同步代码块尽可能使同步代码块中的代码量小

减少锁粒度

  1. 锁分离
  • 读写锁实现了锁分离由读锁和写锁两个锁实现,可以共享读,但只有一个写

  • 读写锁在多线程读写时,读读不互斥,读写互斥,写写互斥

    传统的独占锁在多线程读写时,读读互斥,读写互斥,写写互斥

  • 在读远大于写的多线程场景中,锁分离避免了高并发读情况下的资源竞争,从而避免了上下文切换

  1. 锁分段
  • 在使用锁来保证集合或者大对象的原子性时,可以将锁对象进一步分解
  • Java 1.8之前的ConcurrentHashMap就是用了锁分段(对整个桶数组进⾏了分割分段( Segment ),每⼀把锁只锁容器其中⼀部分数据,多线程访问容器⾥不同数据段的数据,就不会存在锁竞争,提⾼并发访问率。 )

非阻塞乐观锁代替竞争锁

  1. volatile
  • volatile关键字的作用是保证可见性和有序性,volatile的读写操作不会导致上下文切换,开销较小
  • 由于volatile关键字没有锁的排它性,因此不能保证操作变量的原子性
  1. CAS
  • CAS是一个原子的if-then-act操作
  • CAS是一个无锁算法实现,保障了对一个共享变量读写操作的一致性
  • CAS不会导致上下文切换,Java的Atomic包就使用了CAS算法来更新数据,而不需要额外加锁

wait/notify优化

可以通过Object对象的wait、notify、notifyAll来实现线程间的通信,例如生产者-消费者模型

1.wait/notify的使用导致了较多的上下文切换

2.消费者第一次申请到锁,却发现没有内容可消费,执行wait,这会导致线程挂起,进入阻塞状态,这是一次上下文切换

3.当生产者获得锁并执行notifyAll之后,会唤醒处于阻塞状态的消费者线程,又会发生一次上下文切换

4.被唤醒的线程在继续运行时,需要再次申请相应对象的内部锁,此时可能需要与其他新来的活跃线程竞争,导致上下文切换

5.如果多个消费者线程同时被阻塞,用notifyAll将唤醒所有阻塞线程,但此时依然没有内容可消费

  • 因此过早地唤醒,也可能导致线程再次进入阻塞状态,从而引起不必要的上下文切换

6.优化方法

  • 可以考虑使用notify代替notifyAll,减少上下文切换
  • 生产者执行完notify/notifyAll之后,尽快释放内部锁,避免被唤醒的线程再次等待该内部锁
  • 为了避免长时间等待,使用wait(long),但线程无法区分其返回是由于等待超时还是被通知线程唤醒,增加上下文切换
  • 建议使用Lock+Condition代替synchronized+wait/notify/notifyAll,来实现等待通知

合理的线程池大小

  1. 线程池的线程数量不宜过大
  2. 一旦线程池的工作线程总数超过系统所拥有的处理器数量,就会导致过多的上下文切换

协程:非阻塞等待

  1. 协程比线程更加轻量,相比于由操作系统内核管理的进程和线程,协程完全由程序本身所控制,即在用户态执行
  2. 协程避免了像线程切换那样产生的上下文切换,在性能方面得到了很大的提升

减少GC频率

  1. GC会导致上下文切换
  2. 很多垃圾回收器在回收旧对象时会产生内存碎片,从而需要进行内存整理,该过程需要移动存活的对象。而移动存活的对象意味着这些对象的内存地址会发生改变,因此在移动对象之前需要暂停线程,完成后再唤醒线程

因此减少GC的频率能够有效的减少上下文切换

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

线程死锁:

**线程死锁描述的是这样⼀种情况:多个线程同时被阻塞,它们中的⼀个或者全部都在等待某个资源被释放。**由于线程被⽆限期地阻塞,因此程序不可能正常终⽌。

如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对⽅的资源,所以这两个线程就会互相等待⽽进⼊死锁状态。

学过操作系统的朋友都知道产⽣死锁必须具备以下四个条件

  1. 互斥条件:该资源任意⼀个时刻只由⼀个线程占⽤

  2. 请求与保持条件:⼀个进程因请求资源⽽阻塞时,对已获得的资源保持不放。

  3. 不剥夺条件:线程已获得的资源在末使⽤完之前不能被其他线程强⾏剥夺,只有⾃⼰使⽤完毕后才释放资源。

  4. 循环等待条件:若⼲进程之间形成⼀种头尾相接的循环等待资源关系。

如何避免线程死锁

我上⾯说了产⽣死锁的四个必要条件,为了避免死锁,我们只要破坏产⽣死锁的四个条件中的其中⼀个就可以了。现在我们来挨个分析⼀下:

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

  2. 破坏请求与保持条件 :⼀次性申请所有的资源。

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

  4. 破坏循环等待条件 :靠按序申请资源来预防。按某⼀顺序申请资源,释放资源则反序释放。破坏循环等待条件。

我们对线程 2 的代码修改成下⾯这样就不会产⽣死锁了。

预防死锁

这是一种较简单和直观的事先预防的方法。方法是通过设置某些限制条件,去破坏产生死锁的四个必要条件中的一个或者几个,来预防发生死锁。预防死锁是一种较易实现的方法,已被广泛使用。但是由于所施加的限制条件往往太严格,可能会导致系统资源利用率和系统吞吐量降低。

解除死锁

这是与检测死锁相配套的一种措施。当检测到系统中已发生死锁时,须将进程从死锁状态中解脱出来。常用的实施方法是撤销或挂起一些进程,以便回收一些资源,再将这些资源分配给已处于阻塞状态的进程,使之转为就绪状态,以继续运行。死锁的检测和解除措施,有可能使系统获得较好的资源利用率和吞吐量,但在实现上难度也最大。(资源剥夺法,撤销进程法,进程回退法)

sleep()和wait()方法区别和共同点

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

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

  • 线程启动必须使用start(启动一个新线程)

  • 而run只是在当前线程内执行,不能起到异步,提高性能的效果

  • 线程启动之前执行start前的状态:NEW(新建状态),线程启动之后执行start后的状态:RUNNABLE(运行状态)

  • start方法不能被多次调用,否则报异常:java.lang.IllegalThreadStateException

new ⼀个 Thread,线程进⼊了新建状态。调⽤ start() ⽅法,会启动⼀个线程并使线程进⼊了就绪状态,当分配到时间⽚后就可以开始运⾏了。 start() 会执⾏线程的相应准备⼯作,然后⾃动执⾏ run() ⽅法的内容,这是真正的多线程⼯作。

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

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

说说对synchronized关键字的了解?

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

Java 6 之后 Java 官⽅对从 JVM 层⾯对 synchronized ᫾⼤优化,所以现在的synchronized 锁效率也优化得很不错了。JDK1.6 对锁的实现引⼊了⼤量的优化,如⾃旋锁、适应性⾃旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。所以,你会发现⽬前的话,不论是各种开源框架还是 JDK 源码都⼤量使⽤了 synchronized 关键字。

线程池

线程池原理:

一个线程池管理了一组工作线程同时它还包括了一个用于放置等待执行 任务的任务队列(阻塞队列)。
     默认情况下,在创建了线程池后,线程池中的线程数为 0。当任务提交给 线程池之后的处理策略如下:

       1. 如果此时线程池中的数量**小于 corePoolSize(核心线程数(或核心池的大小))**,既使线程池中的线程都处于空闲状态,也要创建新的线程来处理被添加的任务(**也 就是每来一个任务,就要创建一个线程来执行任务**)。
          果此时线程池中的数量**大于等于 核心线程数**,但是***缓冲队列 workQueue 未满***,那么任务会被放入缓冲队列,则该任务会等待空闲线程将其 取出去执行。
                     3. 如果此时线程池中的数量**大于等于 核心线程数****, **缓冲队列 workQueue 满**,并且线程池中的数量***小于 maximumPoolSize(线程池 最大线程数),**建新的线程来处理被添加的任务。**
                        果此时线程池中的数量**大于等于 核心线程数**, **缓冲队列 workQueue 满,并且线程池中的数量等于最大线程数**,那么通 过任务拒绝策略来处理此任务。 也就是处理任务的优先级为: **核 心 线 程 corePoolSize 、 任 务 队 列 workQueue、最大线程 maximumPoolSize,**如果三者都满了,使用 handler 处理被拒绝的任务。
                                   5. 特别注意,**在 核心线程数 和 最大线程数 之间的线程 数会被自动释放**。当线程池中线程数量**大于 核心线程数时**,如果某线程 空闲时间超过 keepAliveTime,线程将被终止,直至线程池中的线程数目不 大于 核心线程数。这样,线程池可以动态的调整池中的线程数。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6klfnaIL-1632497413406)(C:\Users\LENOVO-LX\AppData\Roaming\Typora\typora-user-images\image-20210914202500236.png)]

我们在代码中模拟了 10 个任务,我们配置的核⼼线程数为 5 、等待队列容量为 100 ,所以每次只可能存在 5 个任务同时执⾏,剩下的 5 个任务会被放到等待队列中去。当前的 5 个任务之⾏完成后,才会之⾏剩下的 5 个任务。

线程池提供了⼀种限制和管理资源(包括执⾏⼀个任务)。 每个线程池还维护⼀些基本统计信息,例如已完成任务的数量。

使⽤线程池的好处

  • 降低资源消耗。通过重复利⽤已创建的线程降低线程创建和销毁造成的消耗。
  • 提⾼响应速度。当任务到达时,任务可以不需要等到线程创建就能⽴即执⾏。
  • 提⾼线程的可管理性。线程是稀缺资源,如果⽆限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使⽤线程池可以进⾏统⼀的分配,调优和监控。

简单线程池的创建:

⽅式⼀:通过构造⽅法实现

⽅式⼆:通过 Executor 框架的⼯具类 Executors 来实现

我们可以创建三种类型的 ThreadPoolExecutor:

  • FixedThreadPool该⽅法返回⼀个固定线程数量的线程池。该线程池中的线程数量始终不变。当有⼀个新的任务提交时,线程池中若有空闲线程,则⽴即执⾏。若没有,则新的任务会被暂存在⼀个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
  • SingleThreadExecutor**:** ⽅法返回⼀个只有⼀个线程的线程池。若多余⼀个任务被提交到该线程池,任务会被保存在⼀个任务队列中,待线程空闲,按先⼊先出的顺序执⾏队列中的任务。
  • CachedThreadPool**:** 该⽅法返回⼀个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复⽤,则会优先使⽤可复⽤的线程。若所有线程均在⼯作,⼜有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执⾏完毕后,将返回线程池进⾏复⽤。

一个典型的线程池,应该包括如下几个部分:
1、线程池管理器(ThreadPool),用于启动、停用,管理线程池
2、工作线程(WorkThread),线程池中的线程
3、请求接口(WorkRequest),创建请求对象,以供工作线程调度任务的执行
4、请求队列(RequestQueue),用于存放和提取请求
5、结果队列(ResultQueue),用于存储请求执行后返回的结果

线程池管理器,通过添加请求的方法(putRequest)向请求队列(RequestQueue) 添加请求,这些请求事先需要实现请求接口,即传递工作函数、参数、结果处理函数、以 及异常处理函数。之后初始化一定数量的工作线程,这些线程通过轮询的方式不断查看请 求队列(RequestQueue),只要有请求存在,则会提取出请求,进行执行。然后,线程池 管理器调用方法(poll)查看结果队列(resultQueue)是否有值,如果有值,则取出,调 用结果处理函数执行。

因此,对这个队列的设计,要实现线程同步,以及一定阻塞和超时机制的设计, 以防止因为不断轮询而导致的过多 cpu 开销。

如何合理的配置 java 线程池?如 CPU 密集型的任务,基本线程池 应该配置多大?IO 密集型的任务,基本线程池应该配置多大?用有界 队列好还是无界队列好?任务非常多的时候,使用什么阻塞队列能获取 最好的吞吐量?

答:

1)配置线程池时,CPU 密集型任务可以少配置线程数,大概和机器的 cpu 核数相 当,可以使得每个线程都在执行任务。
     2)IO 密集型任务则由于需要等待 IO 操作,线程并不是一直在执行任务,则配置尽 可能多的线程,2*cpu 核数。
     3)有界队列和无界队列的配置需区分业务场景,一般情况下配置有界队列,在一些 可能会有爆发性增长的情况下使用无界队列。
     4)任务非常多时,使用非阻塞队列,使用 CAS 操作替代锁可以获得好的吞吐量(减少了上下文切换)。 synchronousQueue 吞吐率最高。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

李莲花*

多谢多谢,来自一名大学生的感谢

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值