大三Java后端暑期实习面经总结——Java多线程并发篇

Posted Baret-H

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了大三Java后端暑期实习面经总结——Java多线程并发篇相关的知识,希望对你有一定的参考价值。

img
博主现在大三在读,从三月开始找暑期实习,暑假准备去tx实习啦!总结下了很多面试真题,希望能帮助正在找工作的大家!相关参考都会标注原文链接,尊重原创!



参考:


1. synchronized底层实现原理

synchronized有两种形式上锁:同步方法、同步代码块。它们底层实现其实都一样,在进入同步代码之前先获取锁,获取到锁之后锁的计数器+1,同步代码执行完锁的计数器-1,如果获取失败就阻塞式等待锁的释放。只是他们在同步块识别方式上有所不一样,从class字节码文件可以表现出来,一个是通过方法flags标志,一个是monitorenter和monitorexit指令操作。

1️⃣ 同步代码块

//synchronized修饰代码块
public class Test implements Runnable {
    @Override
    public void run() {
        // 加锁操作
        synchronized (this) {
            System.out.println("hello");
        }
    }

    public static void main(String[] args) {
        Test test = new Test();
        Thread thread = new Thread(test);
        thread.start();
    }
}

javap -c查看相应的class文件:
image-20210428190053555
可以看出在执行同步代码块之前之后都有一个monitor字样,其中前面的是monitorenter,后面的是离开monitorexit,不难想象一个线程也执行同步代码块,首先要获取锁,而获取锁的过程就是monitorenter ,在执行完代码块之后,要释放锁,释放锁就是执行monitorexit指令。

为什么会有两个monitorexit呢?

这个主要是防止在同步代码块中线程因异常退出,而锁没有得到释放,这必然会造成死锁(等待的线程永远获取不到锁)。因此最后一个monitorexit是保证在异常情况下,锁也可以得到释放,避免死锁。


2️⃣ 同步方法

public class Test implements Runnable {
    @Override
    public synchronized void run() {
        System.out.println("hello again");
    }

    public static void main(String[] args) {
        Test test = new Test();
        Thread thread = new Thread(test);
        thread.start();
    }
}

再次用javap -c查看相应的class文件:
在这里插入图片描述
仅有ACC_SYNCHRONIZED这么一个标志,该标记表明线程进入该方法时,需要monitorenter,退出该方法时需要monitorexit


Monitor 可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个 Java 对象就有一把看不见的锁,称为内部锁或者 Monitor 锁。

Monitor 是线程私有的数据结构,每一个线程都有一个可用 monitor record 列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个 monitor 关联,同时 monitor 中有一个 Owner 字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为重量级锁。

随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。JDK 1.6中默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking=false来禁用偏向锁。


2. sychronized锁升级过程

深入理解synchronized底层原理,一篇文章就够了!

synchronized的底层实现原理及各种优化

关于 锁的四种状态与锁升级过程 图文详解

1. Java对象头

synchronized 用的锁是存在Java对象头里的,那么什么是对象头呢?
image-20210510220002205
在JVM中,对象是分成三部分存在的:对象头、实例数据、对其填充。实例数据存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐;对其填充不是必须部分,由于虚拟机要求对象起始地址必须是8字节的整数倍,对齐填充仅仅是为了使字节对齐。
img
以上两者与synchronized无关,==对象头==是我们需要关注的重点,它是synchronized实现锁的基础,因为synchronized申请锁、上锁、释放锁都与对象头有关。

我们以 Hotspot 虚拟机为例,Hopspot 对象头主要包括两部分数据:Mark Word(标记字段)和Klass Pointer(类型指针)

  • Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
  • Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

在上面中我们知道了,synchronized 用的锁是存在Java对象头里的,那么具体是存在对象头哪里呢?答案是:存在锁对象的对象头的Mark Word中,那么MarkWord在对象头中到底长什么样,它到底存储了什么呢?

在64位的虚拟机中:
在这里插入图片描述
在32位的虚拟机中:
image-20210508083743832
下面我们以 32位虚拟机为例,来看一下其 Mark Word 的字节具体是如何分配的

无锁:对象头开辟 25bit 的空间用来存储对象的 hashcode ,4bit 用于存放对象分代年龄,1bit 用来存放是否偏向锁的标识位,2bit 用来存放锁标识位为01

偏向锁: 在偏向锁中划分更细,还是开辟 25bit 的空间,其中23bit 用来存放线程ID,2bit 用来存放 Epoch,4bit 存放对象分代年龄,1bit 存放是否偏向锁标识, 0表示无锁,1表示偏向锁,锁的标识位还是01

轻量级锁:在轻量级锁中直接开辟 30bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为00

重量级锁: 在重量级锁中和轻量级锁一样,30bit 的空间用来存放指向重量级锁的指针,2bit 存放锁的标识位,为11

GC标记: 开辟30bit 的内存空间却没有占用,2bit 空间存放锁标志位为11。

2. 锁的状态

锁的状态总共有四种,级别由低到高依次为:无锁、偏向锁、轻量级锁、重量级锁,这四种锁状态分别代表什么,为什么会有锁升级?

其实在 JDK 1.6之前,synchronized 还是一个重量级锁,是一个效率比较低下的锁,最初的实现方式是 “阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长”,这种方式就是 synchronized实现同步最初的方式,这也是当初开发者诟病的地方,这也是在JDK6以前 synchronized效率低下的原因

在JDK 1.6后,Jvm为了提高锁的获取与释放效率对synchronized进行了优化,引入了 偏向锁 和 轻量级锁 ,从此以后锁的状态就有了四种(无锁、偏向锁、轻量级锁、重量级锁),并且四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,也就是说只能进行锁升级(从低级别到高级别),不能锁降级(高级别到低级别),意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
在这里插入图片描述
锁的类型和状态在对象头Mark Word中都有记录,在申请锁、锁升级等过程中JVM都需要读取对象的Mark Word数据。

锁状态存储内容标志位
无锁对象的hashCode、对象分代年龄、是否是偏向锁(0)01
偏向锁偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1)01
轻量级锁指向栈中锁记录的指针00
重量级锁指向互斥量的指针11

锁对比

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗适用于只有一个线程访问同步块场景
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度如果始终得不到索竞争的线程,使用自旋会消耗CPU追求响应速度,同步块执行速度非常快
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢追求吞吐量,同步块执行速度较慢

— 无锁

无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的特点是修改操作会在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。

— 偏向锁

偏向锁针对的是锁不存在竞争,每次仅有一个线程来获取该锁,为了提高获取锁的效率,因此将该锁偏向该线程。提升性能。

初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。

偏向锁是指当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,即提高性能。

当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。

关于偏向锁的撤销,需要等待全局安全点,即在某个时间点上没有字节码正在执行时,它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态。

— 轻量级锁

偏向锁考虑的是不存在多个线程竞争同一把锁,而轻量级锁考虑的是,多个线程不会在同一时刻来竞争同一把锁。

轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋(关于自旋的介绍见文末)的形式尝试获取锁,线程不会阻塞,从而提高性能。

轻量级锁的获取主要由两种情况:
① 当关闭偏向锁功能时;
② 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。

一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。

在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。

长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。

— 重量级锁

重量级锁描述同一时刻有多个线程竞争同一把锁。

重量级锁显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。

重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。

简言之,就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资源

3. 其他优化

从最近几个jdk版本中可以看出,Java的开发团队一直在对synchronized优化,其中最大的一次优化就是在jdk6的时候,除了新增了两个锁状态,还通过锁消除、锁粗化、自旋锁等方法使用各种场景,给synchronized性能带来了很大的提升

  • 自旋锁

    线程未获得锁后,不是一昧的阻塞,而是让线程不断尝试获取锁。

    缺点:若线程占用锁时间过长,导致CPU资源白白浪费。

    解决方式:当尝试次数达到每个值得时候,线程挂起。

  • 自适应自旋锁

    自旋得次数由上一次获取锁的自旋次数决定,次数稍微延长一点点。

  • 锁消除

    对于线程的私有变量,不存在并发问题,没有必要加锁,即使加锁编译后,也会去掉。

  • 锁粗化

    当一个循环中存在加锁操作时,可以将加锁操作提到循环外面执行,一次加锁代替多次加锁,提升性能


3. ThreadLocal原理和使用场景

image-20210508091655837

1. 什么是ThreadLocal

ThreadLocal 是线程本地存储,在每个线程中都创建了一个ThreadLocalMap 对象,它存储本线程中所有ThreadLocal对象及其对应的值,每个线程可以访问自己内部 ThreadLocalMap 对象内的 value。
image-20210430081636214
由于每一条线程都含有线程私有的ThreadLocalMap容器,这些容器间相互独立不影响,因此不会存在线程安全的问题,从而无需使用同步机制来保证多条线程访问容器的互斥性

ThreadLocalMapThreadLocal的内部类,可以理解为一个map容器,由一个个key-value对象Entry构成

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

看到Entry继承自WeakReferencr<ThreadLocal<?>>,就是一个key-value形式的对象。它的key就是ThreadLocal对象,并且是一个弱引用,如果没有指向key的强引用后,该key就会被垃圾回收器回收;Entry的value就是存储相应ThreadLocal对象的值
image-20210430081946463

2. ThreadLocal的使用

image-20210508091943821

  • 当执行set方法时,ThreadLocal首先会获取当前线程对象,然后获取当前线程的ThreadLocalMap对象,再以当前的ThreadLocal对象为key,将值存入ThreadLocalMap对象中

  • get方法执行过程类似,首先ThreadLocal获取当前线程对象,然后获取当前线程的ThreadLocalMap对象,再以当前的ThreadLocal对象为key,获取对应的value


3. ThreadLocal应用场景

  1. 避免参数的显示传递(比如线程中处理一个非常复杂的业务,有很多方法,使用 ThreadLocal 可以代替一些参数的显式传递,直接从当前线程中存取)
  2. 线程间数据隔离
  3. 进行事务操作时存储线程事务信息,因为事务和线程绑定在一起(Spring在事务开始时会给当前线程绑定一个Jdbc Connection对象,放在ThreadLocal中存储,这样在整个事务执行过程中都是使用该线程绑定的connection来执行数据库操作,实现了事务的隔离性)
  4. 数据库连接(经典的使用场景是为每个线程分配一个JDBC Connection连接对象,这样可以保证每个线程的都在各自的Connection上进行数据库的操作,不会出现A线程关了B线程正在使用的Connection)
  5. session会话等线程级别的操作(Session 的特性很适合 ThreadLocal ,因为 Session 之前当前会话周期内有效,会话结束便销毁)

4. ThreadLocal内存泄漏原因,如何避免

**内存泄露 **为程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存迟早会被占光
简单来说,不再会被使用的对象或者变量占用的内存不能被回收,就是内存泄露。

与OOM的区别:内存泄漏是内存占用无法释放,而OOM是内存不够,内存泄漏会导致OOM

强引用:通过new或反射构造出来的对象都具有强引用,不会被垃圾回收器回收。当内存空间不足时,JVM宁愿OOM报错,使程序异常终止,也不会回收这种对象

🔎 如果想要取消强引用和某个对象之间的关联,可以显示将引用赋值为null,这样JVM就可以在合适的时间对其回收

弱引用:在java中用java.lang.ref.WeakReference类来表示的对象具有弱引用。JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。可以在缓存中使用弱引用。

image-20210430083322215

  • 如果key使用强引用:要回收ThreadLocal时,因为ThreadLocalMap还持有ThreadLocal的强引用,所以除了手动删除,ThreadLocal不会被回收,导致Entry内存泄漏
  • 如果key使用弱引用:要回收ThreadLocal时,因为ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收,此时的Key为null,我们可以在下一次调用ThreadLocalMap的set()、get()、remove()方法来清除value值,避免内存泄漏

因此,ThreadLocal内存泄漏的根本原因是:由于ThreadLocalMap的生命周期和Thread一样长,线程不结束,其中的value值就回收不掉,如果没有手动删除就会导致内存泄漏

ThreadLocal的正确使用方法

  • 每次使用完都调用其remove()方法清除数据
  • 将ThreadLocal变量定义成为private static,这样就一直存在ThreadLocal的强引用,ThreadLocal就不会轻易被回收,可以保证任何时候都能通过ThreadLocal的弱引用访问到Entry的value值,进而清除掉

5. 为什么要用线程池?相关参数?

为什么要使用线程池?

  1. 降低资源消耗,提高线程利用率:不需要反复创建销毁线程,将线程存放在池中,要用直接取出即可,降低创建和销毁线程的消耗
  2. 提高响应速度:任务来了直接取出线程执行,而不是创建线程再执行
  3. 提高线程的管理性:线程的个数是有限的,是稀缺资源,使用线程池可以统一分配调优监控,实现复用资源避免乱用资源

七大参数

  • corePoolSize:核心线程数,就是正常情况下创建的工作线程数,这些线程创建后并不会消除,而是一种常驻线程
  • maxnumPoolSize:最大线程数,它与核心线程数相对应,表示最大允许被创建的线程数,比如当前任务较多,将核心线程数都用完了,还无法满足需求的时候,此时会创建新的线程,但是线程池内的总线程数不会超过最大线程数
  • keepAliveTime:表示超过核心线程数之外的线程的空闲存活时间,也就是核心线程不会被消除,但是超出核心线程数的部分线程如果空闲一定的时间则会被消除,可以通过setKeepAliveTime来设置空闲时间,单位为unit
  • unit:超时单位
  • workQueue:任务队列,用来存放待执行的任务,假设此时的核心线程都已经被使用,还有任务进来则直接放入任务队列,直到整个队列被放满任务还在持续进入则会开始创建新的线程,如果线程达到最大线程数且任务队列也满了,就会执行拒绝策略
  • Handler:任务拒绝策略,有两种情况:第一种是当我们调用shutdown等方法关闭线程池后,此时即使线程内部还有没执行完的任务在执行,但是由于线程池已经关闭,我们再继续让线程池提交任务就会被拒绝。第二种情况是达到最大线程数,线程池已经没有能力继续处理新提交的任务,会拒绝。
  • ThreadFactory:线程工厂,用来生产线程执行任务。我们可以选择使用默认的线程工厂,产生的线程都在同一个组内,拥有相同的优先级,且都不是守护进程。我们也可以选择自定义线程工厂,根据业务的不同指定不同的

6. 线程池的处理流程

image-20210502205048210

7. 线程池中阻塞队列的作用?为什么先添加队列而不是先创建最大线程?

线程池中阻塞队列的作用

  1. 普通队列只能保证作为一个有限长度的缓冲区,如果超出了缓冲长度,就无法保留当前要入队的任务;而阻塞队列可以通过阻塞保留住当前想要继续入队的任务。

  2. 此外,当任务队列中没有任务时,阻塞队列可以阻塞要获取任务的线程,让其进入wait状态,释放cpu资源。

  3. 阻塞队列自带阻塞和唤醒的功能,不需要额外的处理,无任务执行时,线程池利用阻塞队列的take方法将线程挂起,从而维护核心线程的存活,不至于一直占用cpu资源

为什么先添加队列而不是先创建最大线程?

因为创建新线程时,需要获取全局锁,会阻塞其他的线程,十分耗费资源,影响了整体的效率。

就好比一个企业里面有10个(core)正式工的名额,最多招10个正式工,要是任务超过正式工人数(task>core)的情况下,工厂领导(线程池)不是首先扩招工人,还是这10人,但是任务可以稍微积压一下,即先放到队列去(代价低)。10个正式工慢慢干,迟早会千完的,要是任务还在继续增加,超过正式工的加班忍耐极限了队列满了),就的招外包帮忙了(注意是临时工)要是正式工加上外包还是不能完成任务,那新来的任务就会被领导拒绝了(线程池的拒绝策略)


8. 线程池中线程复用原理

线程池将线程和任务进行解耦,线程是线程,任务是任务,摆脱了之前通过 Thread创建线程时的一个线程必须对应一个任务的限制。

在线程池中,同一个线程可以从阻塞队列中不断获取新任务来执行,其核心原理在于线程池对 Thread 进行了封装,并不是每次执行任务都会调用 Thread.start() 来创建新线程,而是让每个线程去执行一个循环任务,在这个循环任务中不停检查是否有任务需要被执行,如果有则直接执行,也就是调用任务的run方法,将run方法作为一个普通的方法来执行,通过这种方式只使用固定的线程就将所有任务的run方法串联起来。

也就是说业务逻辑没有写在线程池中线程的run方法里,而是利用这些线程调用任务里的run方法,实现线程复用


9. 线程的生命周期?有几种状态

线程通常有五种状态:创建、就绪、运行、阻塞、死亡

  1. 新建状态(NEW):新创建了一个线程对象
  2. 就绪状态(Runnable):线程对象创建后,其他线程调用了该对象的start方法。该状态的线程位于可运行线程池中,等待CPU的调度
  3. 运行状态(Running):就绪状态的线程获取了CPU,执行程序代码
  4. 阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态
  5. 死亡状态(Dead):线程执行完了或者因为异常退出了run方法,该线程结束生命周期

其中阻塞的状态分为三种:

  • 等待阻塞:运行的线程执行wait方法,该线程会释放占用的所有资源,JVM会把该线程放入等待池中。进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用notify/notifyAll方法才能唤醒。wait是Object类中的方法
  • 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池
  • 其他阻塞:运行的线程执行sleep()、join(),或发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep超时、join等待线程终止/超时、或I/O处理完毕时,线程重新转入就绪状态。sleep是Thread类中的方法

10. sleep、wait、join、yeild的区别

首先理解两个概念:

  • 锁池:所有需要竟争同步锁的线程都会放在锁池当中,比如当前对象的锁已经被其中一个线程得到,则其他线程需要在这个锁池进行等待,当前面的线程释放同步锁后锁池中的线程去竞争同步锁,当某个线程得到后会进入就绪队列进行等待cpu资源分配
  • 等待池:当我们调用wait()方法后,线程会放到等待池当中,等待池的线程是不会去竞争同步锁。只有调用了notify()或notifyAll后等待池的线程才会开始去竟争锁,notify()是随机从等待池选岀一个线程放到锁池,而notifyAll()是将等待池的所有线程放到锁池当中

sleepwait的区别

  1. sleep是Thread类的静态本地方法,wait则是Object类的本地方法

  2. sleep方法不会释放lock,但是wait会释放,而且会加入到等待池中
    sleep就是把cpu的执行资格和执行权释放出去,不再运行此线程,当定时时间结束再取回cpu资源,参与cpu的调度,获取到cpu资源后就可以继续运行了。而如果sleep时该线程有锁,那么sleep不会释放这个锁,而是把锁带着进入了冻结状态,也就是说其他需要这个锁的线程不可能获取到这个锁。如果在睡眠期间其他线程调用了这个线程的interrupt方法,那么这个线程立即退出被阻塞状态,并抛出一个InterruptedException异常,这点和wait是一样的,以便发生异常中断也可以使wait等待的线程唤醒

  3. sleep方法不依赖于同步器 synchronized,但是wait需要依赖synchronized关键字

  4. sleep不需要被唤醒(休眠之后推出阻塞),但是wait需要(不带参数的方法需要,带时间参数的不需要)

  5. sleep一般用于当前线程休眠,或者轮循暂停操作,wait则多用于多线程之间的通信

  6. sleep会让出CPU执行时间且强制上下文切换,而wait则不一定,wait后可能还是有机会重新竞争到锁继续执行的

yeildjoin

  • yield()执行后线程直接进入就绪状态,马上释放了cpu的执行权,但是依然保留了cpu的执行资格,所以有可能cpu下次进行线程调度还会让这个线程获取到执行权继续执行

  • join()执行后线程进入阻塞状态,例在线程B调用线程A的join(),那线程B会进入到阻塞队列,直到线程A结束或中断线程
    image-20210503103709666


11. 对线程安全的理解

线程安全在三个方面体现:

  • 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,(atomic,synchronized);
  • 可见性:一个线程对主内存的修改可以及时地被其他线程看到,(synchronized,volatile);
  • 有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,(happens-before原则)。

对该问题的考察其实不是线程安全、而是内存安全,堆是共享内存,可以被所有线程访问

当多个线程访问一个对象时,如果不用进行额外的同步控制或其他的协调操作,调用这个对象的行为都可以获得正确的结果,我们就说这个对象是线程安全的,其中所谓正确的结果也就是单线程运行的结果

进程和线程共有的空间,分全局堆局部堆。全局堆就是所有没有分配的空间,局剖堆就是用户分配的空间。堆在操作系统对进程初始化的时候分配,运行过程中也可以向系统要额外的堆,但是用完了要还给操作系统,要不然就是内存泄漏。

在Java中,堆是jvm所管理的内存中最大的一块,是所有线程共享的一块内存区域,在虚拟机启动时创建。堆所存在的内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

是每个线程独有的,保存其运行状态和局部自动变量。栈在线程开始的时候初始化,每个线程的栈互相独立,因此,栈是线程安全的。操作系统在切换线程的时候会自动切换栈。栈空间不需要在高级语言里面显式的分配和释放

目前主流操作系统都是多任务的,即多个进程同时运行。为了保证安全,每个进程只能访问分配给自己的内存空间——栈,而不能访问别的进程的,这是由操作系统保障的。

每个进程的内存空间中都会有一块特殊的公共区域,通常称为堆(内存)。进程内的所有线程都可以访问到该区或,这就是造成问题的潜在原因。


12. Thread和Runnable区别

Thread和Runnable的实质是继承关系,没有可比性。无论使用Runnable还是Thread,都会new Thread,然后执行run方法。用法上,由于Thread实现了Runnable接口,进行了一些功能拓展,因此如果有复杂的线程操作需求,那就选择继承Thread,如果只是简单的执行一个任务,那就实现runnable


13. 说说你对守护线程的理解

守护线程

  • 为所有非守护线程(用户线程)提供服务的线程;任何一个守护线程都是整个jvm中所有非守护线程的保姆
  • 守护线程类似于整个进程中默默无闻的小人物;它的生死无关重要,依赖于整个进程而运行,如果其他线程结束了,没有要执行的了,程序就结束了,此时不会管守护线程的状态如何,直接将其中断

守护进程的作用

  • GC垃圾回收线程就是最经典的一个守护线程,它始终在低级别的状态中运行,用于实时监控和管理系统中可回收的资源。当程序中不再有任何运行的线程时,程序就不再产生垃圾,垃圾回收线程就无事可做,所以当GC垃圾回收线程是jvm上仅剩的线程时,它会自动离开。

使用场景

  1. 为其他线程提供服务支持
  2. 在任何情况下,程序结束时,如果该线程能正常且立刻关闭,则该线程可以作为守护进程来使用;反之,如果一个正在执行某操作的线程必须要正确关闭否则会出现不良后果的话,则不能作为守护线程,而是用户线程,通常都是关键的业务,比如数据录入/更新,这些操作不能中断

注意

  • 由于守护线程的终止是自身无法控制的,因此干万不要把Io、File等重要操作逻辑分配给它;因为它不靠谱

  • thread.setDaemen(true)必须在thread.start()之前设置,否则会抛出inllegalThreadStateException异常,不能将正在运行的用户线程转换为守护线程

  • 在守护进程中新产生的线程也是守护线程

  • 守护线程不能用于访问固有资源,比如读写操作/计算逻辑。因为它会在任意时候发生中断

  • Java自带的多线程框架如ExecutorService,即使设置为了用户线程,也会自动将守护线程转换为用户线程,所以要使用守护进程不能使用Java的线程池


14. 乐观锁/悲观锁

悲观锁(Pessimistic Lock)

1️⃣ 简介

​ 当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。这种借助数据库锁机制,在修改数据之前先锁定,再修改的方式被称之为悲观并发控制【Pessimistic Concurrency Control,缩写“PCC”,又名“悲观锁”】

​ 悲观锁,正如其名,具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度。因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。

img

之所以叫做悲观锁,是因为这是一种对数据的修改持有悲观态度的并发控制方式。总是假设最坏的情况,每次读取数据的时候都默认其他线程会更改数据,因此需要进行加锁操作,当其他线程想要访问数据时,都需要阻塞挂起。

2️⃣ 实现

  1. 传统的关系型数据库使用这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
  2. Java 里面的同步 synchronized 关键字的实现。

3️⃣ 分类

悲观锁主要分为 共享锁排他锁

  • 共享锁【shared locks】又称为读锁,简称S锁。顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。
  • 排他锁【exclusive locks】又称为写锁,简称X锁。顾名思义,排他锁就是不能与其他锁并存,如果一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,但是获取排他锁的事务是可以对数据行读取和修改。

4️⃣ 说明

​ 悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会。另外还会降低并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数据。

乐观锁(Optimistic Locking)

1️⃣ 简介

​ 乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则返回给用户错误的信息,让用户决定如何去做。乐观锁适用于读操作多的场景,这样可以提高程序的吞吐量。

img

​ 乐观锁机制采取了更加宽松的加锁机制。乐观锁是相对悲观锁而言,也是为了避免数据库幻读、业务处理时间过长等原因引起数据处理错误的一种机制,但乐观锁不会刻意使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性。

2️⃣ 实现

  1. CAS实现:Java中java.util.concurrent.atomic包下面的原子变量使用了乐观锁的一种CAS实现方式

    //:这里的第二个参数等同于乐观锁的version,初始值设为1
    public AtomicStampedReference(V initialRef, int initialStamp)
    
  2. 版本号控制:一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会+1。当线程A要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值与当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功

3️⃣ 说明

​ 乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁


15. 乐观锁和悲观锁如何选择

乐观锁和悲观锁哪个好?

两种锁各有优缺点,不可认为一种好于另一种,比如像乐观锁适用于写比较少的情况下,冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果经常产生冲突,上层应用会不断的进行retry,这样反倒是降低了性能,所以这种情况下用悲观锁就比较合适。

本质上,mysql的乐观锁与悲观锁主要都是用来解决并发的场景,避免丢失更新问题。

  • 乐观锁:比较适合读取操作比较频繁的场景,如果出现大量的写入操作,数据发生冲突的可能性就会增大,为了保证数据的一致性,应用层需要不断的重新获取数据,这样会增加大量的查询操作,降低了系统的吞吐量
  • 悲观锁:比较适合写入操作比较频繁的场景,如果出现大量的读取操作,每次读取的时候都会进行加锁,这样会增加大量的锁的开销,降低了系统的吞吐量。

总结

  1. 要记住锁机制一定要在事务中才能生效,事务也就要基于MySQL InnoDB 引擎。
  2. 访问量不大,不会造成压力时使用悲观锁,面对高并发的情况下,我们应该使用乐观锁。
  3. 读取频繁时使用乐观锁,写入频繁时则使用悲观锁。还有一点:乐观锁不能解决脏读的问题。

16. 公平/非公平锁

  • 公平锁:非常公平,不能插队,线程的执行必须先来后到
  • 非公平锁:非常不公平,可以插队,默认都为非公平锁!(比如一个线程3s执行完,一个线程1min执行完,如果使用公平锁严重影响某个线程的效率)

image-20210321133926406


17. 可重入锁

广义上的可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁(前提得是同一个对象或者class),这样的锁就叫做可重入锁。

  • 不可重入锁与可重入锁相反,不可递归调用,递归调用就发生死锁。
image-20210412012855770

代码示例:synchronized版

image-20210412013050474

执行结果:

image-20210412013256865
代码示例:Lock版

image-20210412013522089

18. 自旋锁

不断的尝试,直到成功为止!
image-20210412014256436
我们来编写一个自旋锁,利用CAS实现

package 自旋锁;

import java.util.concurrent.atomic.AtomicReference;

//自定义自旋锁
public class SpinLock {
    //存放线程的原子引用
    AtomicReference<Thread> atomicReference = new AtomicReference<>();

    //加锁,需要自旋
    public void myLock() {
        Thread thread = Thread.currentThread();
        System.out.println(thread.getName() + "==>myLock");
        //自旋锁:如果atomicReference为空,则将当前线程存入atomicReference
        while (!atomicReference.compareAndSet(null, thread)) ;
    }

    //解锁,不需要自旋
    public void myUnlock() {
        Thread thread = Thread.currentThread();
        System.out.println(thread.getName() + "==>myUnLock");
        //自旋锁:如果atomicReference为当前线程,则将当前线程置空
        atomicReference.compareAndSet(thread, null);
    }
}

然后编写一段测试代码

package 自旋锁;

import java.util.concurrent.TimeUnit;

public class Test {
    public static void main(String[] args) throws InterruptedException {
        Spin

以上是关于大三Java后端暑期实习面经总结——Java多线程并发篇的主要内容,如果未能解决你的问题,请参考以下文章

大三Java后端暑期实习面经总结——Java容器篇

大三Java后端暑期实习面经总结——Java容器篇

大三Java后端暑期实习面经总结——Java基础篇

大三Java后端暑期实习面经总结——Java基础篇

大三Java后端暑期实习面经总结——JVM篇

大三Java后端暑期实习面经总结——JVM篇