banner
这东西看的我想死啊

Java并发编程(1)

Scroll down

本文主要摘自JavaGuide面试指南,笔者根据自身的实际情况进行了一定的删改。原文链接:https://javaguide.cn/java/concurrent/java-concurrent-questions-02.html

什么是协程?协程、线程和进程的区别

进程:操作系统进行资源调度和分配的最小单位

线程:程序执行的最小单位

协程:一种轻量级线程,协程的切换可以用程序自己控制,切换创建开销小,不需要锁,因为同时只有一个协程在执行

线程 VS 协程

线程 协程 结果
占用资源 默认Stack大小是1M 更轻量,接近1K 相同的内存中开启更多的协程
切换开销 由操作系统来切换,发生在内核态上 由程序控制,发生在用户态上 协程的开销远远小于线程的开销
数据同步 需要用锁来保证数据一致性和可见性 不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突, 协程的执行效率比多线程高很多

为什么要使用多线程?

  • 线程可以看作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度成本远远小于进程
  • 多线程可以提高进程利用单核/多核CPU效率,提高系统整体的并发能力以及性能

多线程可能带来的问题?

并发编程并不总能提高程序运行速度,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等

说说线程的生命周期和状态?

  • NEW:初始状态,线程被创建出来但没有被调用start()
  • RUNNABLE:运行状态,线程被调用了start()
  • BLOCKED:阻塞状态,需要等待锁释放
  • WAITING:等待状态,表示该线程需要等待其他线程的通知
  • TIME_WAITING:超时等待状态,在指定时间后回到RUNNABLE状态
  • TERMINATED:终止状态,表示该线程已经运行完毕

什么是上下文切换?其流程是什么

上下文:线程在执行过程中自己的运行条件和状态,比如程序计数器、栈信息等

上下文切换:保存当前线程的上下文,留待线程下次占用CPU时恢复现场,并加载下一个将要占用CPU的线程上下文

以下情况会发生线程切换:

  • 主动让出CPU,比如调用了sleep()wait()
  • 时间片用完
  • 调用了阻塞类型的系统中断

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

线程死锁:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放

产生死锁的四个必要条件

  • 互斥条件:一个资源任意时刻只有一个线程占用
  • 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放
  • 不可剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺
  • 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系

如何预防死锁

破坏死锁的产生的必要条件:

  • 破坏请求与保持条件:一次性申请所有的资源
  • 破坏不可剥夺条件:线程如果申请不到其他资源时,可以主动释放其占有的资源
  • 破坏循环等待条件:按某一顺序申请资源,释放时反序释放

如何避免死锁

在资源分配时,借助于算法(如银行家算法)对资源分配进行计算评估,使其进入安全状态。

安全状态:系统按照某种线程推进顺序来为每个线程分配所需资源,每个线程都可顺利完成,则为安全状态,该推进顺序为一个安全序列。

sleep()方法和wait()方法的对比?

相同:两者都可以暂停线程的执行

区别:

  • sleep()方法没有释放锁,而wait()方法释放了锁
  • wait()通常用于线程间交互/通信,sleep()通常被用于暂停执行
  • wait()方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()或者notifyAll()方法;sleep()方法执行完成后,线程会自动苏醒,或者使用wait(long timeout)超时后线程会自动苏醒
  • sleep()是Thread类的静态本地方法,wait()则是Object类的本地方法

为什么wait()方法不定义在Thread中?

wait()是让获得对象锁的线程实现等待,实现进程间的同步,会自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入WAITING状态,因此需要操作对应的对象(Object)而非当前的线程(Thread)

为什么sleep()方法定义在Thread中

sleep()是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁

可以直接调用Thread类的run方法吗?

调用start()方法可启动线程并使线程进入就绪状态,但直接执行run()方法不会以多线程的方式执行

new一个Thread,线程进入新建状态。调用start()方法,会启动该线程并进入RUNNABLE状态。start()会执行线程的相应准备工作,然后自动执行run()方法的内容,只是真正的多线程工作。但是直接调用run()方法,会把run()方法当成一个main线程下的普通方法去执行,并不会在某个线程中执行它,所以并不是多线程工作

了解Java内存模型吗?

//TODO

volatile关键字

volatile关键字作用:

  1. 保证变量的可见性
  2. 防止JVM的指令重排序

如何保证变量的可见性?

volatile关键字可以保证变量的可见性,其指示JVM,被该关键字修饰的变量是共享且不稳定的,每次使用它都需要到主存(主内存)中进行读取,即禁止使用缓存

volatile关键字能保证数据的可见性,但不能保证数据的原子性,synchronized关键字两者都能保证

  • 可见性,一个线程对变量的修改会被实时写回至主存中,对于其他线程来说可以实时得到其修改后的值
  • 原子性,一条指令的所有操作,要么全做,要么全不做

如何禁止指令重排序?

  • volatile关键字可以防止JVM的指令重排序,如果将变量声明为volatile,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序

  • Java中的Unsafe类提供三个内存屏障相关的方法,通过这三个方法也可以实现禁止指令重排序的的效果,但相对麻烦一些

    1
    2
    3
    public native void loadFence();
    public native void storeFence();
    public native void fullFence();

volatile可以保证原子性吗?

volatile关键字能保证变量的可见性,但不能保证对变量操作的原子性,可以使用synchronized或者lock

乐观锁和悲观锁

什么是悲观锁?使用场景是什么?

  • 悲观锁:总是假设最坏的情况,认为共享资源每次被访问时会发生问题,所以每次获取资源操作时会上锁,其他线程尝试获取该资源时会被阻塞,即共享资源每次只给一个线程使用,其他线程阻塞,用完后再把资源转让给其他线程。

  • Java中的synchronizedReentrantLock等独占锁就是悲观锁思想的实现

  • 悲观锁通常多用于多写场景,避免频繁失败和重试影响性能

什么是乐观锁?使用场景是什么?

  • 乐观锁:总是假设最好的情况,认为共享资源每次被访问时不会发生问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改时验证对应的资源(数据)是否被其他线程修改(具体可用版本号机制或CAS算法)

  • Java中java.util.concurrent.atomic包下的原子变量类就是使用乐观锁的一种实现方式CAS实现的

  • 乐观锁通常多用于多读场景,避免频繁加锁影响性能,大大提升系统的吞吐量

如何实现乐观锁?

乐观锁一般使用版本号机制CAS算法实现

版本号机制

一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数。当数据被修改时,version值会加一。线程在提交更新时,只有读到的version值和先前的version值相等时才能提交更新后的数据

CAS算法

CAS的全称是Compare And Swap(比较与交换),用于实现乐观锁,其思想是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新

CAS是一个原子操作,底层依赖于一条CPU的原子指令

CAS涉及到三个操作数:

  • V:要更新的变量值(Var)
  • E:预期值(Expected)
  • N:拟写入的新值(New)

当且仅当V的值等于E时,CAS通过原子方式用新值N来更新V的值和E的值,如果不等,说明已经有其他线程更新了V,当前线程放弃更新并重新执行刚才的操作。

CAS存在哪些问题?

  1. ABA问题:数据从A->B->A,CAS检查时会发现它的值没有发生改变,可以用版本号机制来解决,JDK 1.5以后的AtomicStampedReference类就是用来解决ABA问题的,其中的compareAndSet()方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,就以原子方式将该引用和该标志的值设置为给定的更新值
  2. 循环时间长开销大:CAS经常会用到自旋操作来进行重试,如果长时间不成功,会给CPU带来非常大的执行开销
  3. 只能保证一个共享变量的原子操作:CAS只对单个共享变量有效,当操作涉及跨多个共享变量时CAS无效。从JDK 1.5开始提供AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作

synchronized关键字

synchronized是什么?有什么用?

synchronized主要解决多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行

如何使用synchronized?

  1. 修饰实例方法(锁当前类的对象)

    1
    synchronized void method(){}
  2. 修饰静态方法(锁当前类所有对象)

    1
    synchronized static void method(){}
  3. 修饰代码块(锁指定对象/类)

    1
    2
    synchronized(object){};
    synchronized(类.class){};

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

构造方法可以用synchronized修饰吗?

不可以,因为构造方法本身就属于线程安全的,不存在同步的构造方法一说。

synchronized实现了什么类型的锁

  • 悲观锁:每次访问共享资源时都会上锁
  • 非公平锁:线程获取锁的顺序不一定是按照线程阻塞的顺序
  • 可重入锁:已经获取锁的线程可以再次获取锁
  • 独占锁/排他锁:该锁只能由一个线程持有,其他线程都会被阻塞

synchronized底层实现原理了解吗?

同步代码块:通过对象的监视器monitor的monitorenter和monitorexit实现

1
2
3
synchronized (this) {
System.out.println("testSync");
}

编译后的字节码

![image-20241217111234420](/Users/effy/Library/Application Support/typora-user-images/image-20241217111234420.png)

同步方法:通过方法访问标志ACC_SYNCHRONIZED实现

1
2
3
public synchronized void testSync2() {
System.out.println("testSync2");
}

编译后的字节码文件:

![image-20241217111605214](/Users/effy/Library/Application Support/typora-user-images/image-20241217111605214.png)

什么是synchronized锁升级?

背景:JDK1.5及以前,synchronized加锁和释放锁JVM底层都会通过操作系统实现,会涉及上下文的切换(用户态到内核态),性能消耗极高,是公认的重量级锁。JDK1.6后对synchronized优化,引入“偏向锁”和“轻量级锁”

  1. 无锁

    当JVM启动后,一个共享资源对象直到有线程第一个访问时,这段时间内是处于无锁状态,对象头的Markword里偏向锁标识位是0,锁标识位是01

    img

  2. 偏向锁(jdk1.6后默认打开,-XX:+UseBiasedLocking关闭偏向锁)

    当一个共享资源首次被某个线程访问时,锁就会从无锁状态升级到偏向锁状态,偏向锁会在Markword的偏向线程ID里存储当前线程的操作系统线程ID,偏向锁标识位是1,锁标识位是01。此后如果当前线程再次进入临界区域时,只比较这个偏向线程ID即可,这种情况是在只有一个线程访问的情况下,不再需要操作系统的重量级锁来切换上下文,提供程序的访问效率。

    img

  3. 轻量级锁

    当前共享资源锁已经是偏向锁时,再有第二个线程访问共享资源锁时,如有没有竞争,那么偏向锁的效率更高(因为频繁的锁竞争会导致偏向锁的撤销和升级到轻量级锁),继续保持偏向锁。如果有竞争,则锁状态会从偏向锁升级到轻量级锁

    img

  4. 重量级锁

    若当前只有一个等待线程,则可通过自旋继续尝试, 当自旋超过一定的次数,或者一个线程在持有锁,一个线程在自旋,又有第三个线程来访问时,轻量级锁就会膨胀为重量级锁,此时,JVM会将线程阻塞。

synchronized和volatile有什么区别

  • volatile是线程同步的轻量级实现,所以性能优于synchronized,但volatile关键字是能用于变量而synchronized可以修饰方法以及代码块
  • volatile能保证数据的可见性,但不能保证数据的原子性,synchronized两者都能保证
  • volatile主要用于解决变量在多个线程之间的可见性,而synchronized解决的是多个线程之间访问资源的同步性

synchronized和ReentrantLock有什么区别

两者都是可重入锁

可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其想要再次获取这个对象的锁的时候还是可以获取的,如果是不可重入锁,就会造成死锁

sychronized依赖于JVM而ReentrantLock依赖于API

  • synchronized 是依赖于 JVM 实现的,虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们

  • ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以可以通过查看它的源代码,来看它是如何实现的

ReentrantLock

ReentrantLock是什么?

ReentrantLock实现了Lock接口,是一个可重入且独占式的锁,和synchronized关键字类似,但它更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能

公平锁和非公平锁有什么区别?

  • 公平锁:锁被释放后,按照申请顺序将锁给下一个线程。性能较差一些,因为公平锁为保证时间上的绝对顺序,上下文切换更频繁
  • 非公平锁:锁被释放后,随机或者按照其他优先级将锁给下一个进程。性能更好,但可能导致某些线程永远无法获取到锁

ReentrantLock 比 synchronized 增加了一些高级功能

  • 等待可中断(等了一半放弃,去做其他事)
  • 可实现公平锁
  • 可实现选择性通知

可中断锁和不可中断锁的区别?

  • 可中断锁:获取锁的过程可以被中断,不需要一直等到获取锁后才能进行其他逻辑处理,如ReentrantLock
  • 不可中断锁:一旦线程申请了锁,必须拿到锁后才能进行其他的逻辑处理,如synchronized
Other Articles
cover
SQL
  • 23/03/07
  • 08:38