一文详解Java并发常见问题

本文章将收录在专栏链接《手把手带你破解银行科技岗面试》,如果你对银行科技岗(研发中心、数据中心、软开中心、金融科技岗、科技人才岗)感兴趣,欢迎链接点击此处订阅本专栏。本专栏将手把手带你破解银行科技岗面试,学习本专栏至少可以让你知道:

我到底能报考哪些银行里的哪些机构?

我到底是否能达到这些岗位的招聘要求?

我到底如何提前准备这些岗位的招聘面试?

一、问题列表

我将面试收集到的高频问题给放在了这里,大家可以查漏补缺

  1. 乐观锁、悲观锁
  2. sychronized
  3. sychronized和lock锁的区别
  4. volatile关键字是做什么用的
  5. ThreadLocal是做什么的?
  6. j.u.c线程池是什么东西?
  7. j.u.c atomic 原子类

二、答案参考

2.1 乐观锁、悲观锁

  • 什么是乐观锁悲观锁?乐观锁对应于生活中乐观的人总是想着事情往好的方向发展,悲观锁对应于生活中悲观的人总是想着事情往坏的方向发展。这两种人各有优缺点,不能不以场景而定说一种人好于另外一种人。

  • 乐观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。

  • 悲观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在 Java 中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

  • 两种锁的场景:从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行 retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

  • 乐观锁的两种实现机制

    • 第一种是版本号机制:一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。

    • 举个版本号机制的例子:假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。

      1. 操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 100-$50 )。
      2. 在操作员 A 操作的过程中,操作员B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 100-$20 )。
      3. 操作员 A 完成了修改工作,将数据版本号加一( version=2 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本大于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。
      4. 操作员 B 完成了操作,也将版本号加一( version=2 )试图向数据库提交数据( balance=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 2 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须大于记录当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。
      5. 这样,就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员A 的操作结果的可能。
    • 第二种是CAS算法:就是即 compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS 算法涉及到三个操作数

    • 需要读写的内存值 V

    • 进行比较的值 A

    • 拟写入的新值 B

    当且仅当 V 的值等于 A 时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。

2.2 sychronized

  • 先从宏观上来讲:主要用于多线程之间访问资源的同步性的问题,可以修饰方法,修饰代码块,主要目的就是保证方法和代码块儿中在任意时刻只有一个线程在执行。在 Java 早期版本里,这个 sychronized 属于重量级锁,因为 java 的线程会映射到操作系统的原生线程上,如果要进行线程切换,是需要操作系统申请互斥量来完成的,这就牵涉到用户态内核态的转换,效率很低。 所以在 java6 之后,从 JVM 层面对 sychronized 进行大量的优化,比如锁升级策略,由无锁、偏向锁、轻量级锁、重量级锁这么一个升级的过程。

  • 再讲他的使用方法有两种,一种是修饰代码块儿,一种是修饰方法。修饰代码块儿的话就是对当前的对象进行加锁,修饰实例方法就是对对象进行加锁,修饰静态方法就是对类进行加锁,作用于类的所有对象实例。

  • 讲讲深层次的原理,每个对象中都内置了一个 ObjectMonitor 对象,本质来讲两种使用方法都是在加锁的时候尝试对 对象监视器 monitor 的获取

    • 同步代码块儿的时候,对.class 文件进行反编译,会发现有这么两个字节码加在代码块儿的前后,一个是 monitorenter 一个是 monitorexit,在 monitorenter 会尝试获取对象的锁,monitorexit 的时候就会释放锁。把 monitor 对象的计数器 加 1,当 monitorexit 时,会对这个 monitor 对象的锁计数器 减 1,当锁计数器到 0 的时候表明锁被释放了,否则线程就要阻塞等待。

    • 同步方法的时候,对 .class 文件进行反编译会发现这个方法有一个 ACC_SYNCHRONIZED 标志位,用来提示 JVM 这是一个同步方法,从而在执行方法时会隐式的执行 monitorenter 和 monitorexit

  • sychronized 锁优化:因为 java6 之前,sychronized 都是重量级的锁,只要用 sychronized 都需要向操作系统申请一把大锁给锁上,消耗很多资源。所以 java6 之后就从 JVM 角度引入了一个锁升级的策略。具体就是一个对象在不同的竞争条件下使用不同的加锁策略。分别从 无锁 到 偏向锁 到 轻量级锁 到 重量级锁。

    • 偏向锁状态*:因为根据统计结果来讲大多数情况下锁不存在多线程的竞争,而且总是由同一个线程多次获得,引入了偏向锁。偏向锁其实就是把对象头的 MarkWord 里放了一个线程的 id,就代表我拿到这个对象的锁了,在接下来的过程中,这个锁没有被其他的线程访问,则持有偏向锁将永远不需要触发同步,也就是说,偏向锁在资源没有竞争的情况下直接不用进行同步了,连 CAS 操作都没有了,提高了性能。

    • 轻量级锁状态:如果出现了两个线程来竞争锁的话,就会对偏向锁执行撤销,并且升级为自旋锁也就是轻量级锁,这个锁会使用原子的 CAS 操作把当前线程 ID 写到对象的 MarkWord 里,如果成功,表示我竞争到了这把锁,如果失败,则继续自旋获取这把锁。如果自旋超过一定的次数,则升级为重量级锁。

    • **重量级锁状态:**其实就是之前提到的,之间上一把大锁,交给操作系统进行调度。

  • 问:偏向锁一定比轻量级锁的效率高吗? 不一定,有锁撤销的过程,如果明确就知道要多线程竞争资源,不如直接轻量级锁。

2.3 sychronized和lock锁的区别

0. 点出锁的实现

synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。

1. 相同点

  • 性能其实大致是相同的:新版本 Java 对 synchronized 进行了很多优化,利用并发程度逐级增加,由无锁到偏向锁到轻量级锁到重量级锁的这么一个锁升级的策略,那么两者的性能大致是一样的。
  • 都是可重入锁:指的是自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁。

2. Lock 锁的高级功能(sychronized 没有)

  • 等待中断功能:sychronized 是不能被中断的,而 Lock 锁提供了 lockInterruptibly 的 api 可以使在等待的线程放弃等待。
  • 公平锁功能:sychronized 就是非公平的,而 Lock 锁提供了构造函数可以在构建实例时选择公平还是非公平的。公平锁就是先到先得,非公平锁就是谁抢到是谁的。
  • 精准通知功能:sychronized 借助 Thread 的 wait() notify() notifyAll() 方法结合可以实现等待通知机制,但 notify() 和 notifyAll() 方法进行通知时,被通知的线程是由 JVM 选择的。而 Lock 锁内提供了 Condition 机制,一个 lock 锁可以绑定多个 Condition 可以实现精准的线程唤醒。

2.4 volatile关键字是做什么用的?

  • 问:讲讲 volatile 关键字? 答:首先 Java 的内存模型 JMM 其实可以分为每个线程的工作区内存大家都可见的主内存(公共的),每个线程不能对主内存的变量进行直接操作,而是在工作内存保存了变量的快照。 基于上面这一点,如果说没有一些控制方法的话,在并发条件下就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还在继续使用他在工作内存中的值,造成数据不一致。而要解决这个问题,可以把变量声明成 volatile 的,这就告诉了所有线程,我这个变量是不稳定的,每次在用之前都需要重新 load 到我的工作内存里,在改完之后需要 store 到主存里。通过这样的方式保证变量的可见性。

  • 问:volatile 可以保证有序性和可见性,但无法保证原子性,为什么无法保证原子性? 答:因为 volatile 只能在 load 到工作内存里的时候值是正确的,我在 store 前可能就有其他的线程也 load 了,所以没办法保证原子性。

  • 问:volatile如何保证有序性呢

    先解释一下有序性的概念:比如我写代码在 new 一个对象的时候,其实简单来说是有三个操作的:balabalabala,然而第二个操作和第三个操作可能会被编译器优化,所以并不是有序的!但如果加上 volatile 的话,就可以禁止这种重排序优化。比如在写双锁单例的时候就必须加一个 volatile 禁止指令重排,否则会发生错误,

2.5 ThreadLocal 是做什么的?

这篇文章讲的太好了,篇幅太大我直接放个链接吧:

ThreadLocal 万字长文解析

2.6 j.u.c线程池是什么东西?

1.what? (什么是线程池)

更愿意把线程池叫做线程管理工具,JVM 每次创建销毁线程需要消耗系统资源,有可能导致每次创建和消耗系统资源比处理任务花费的时间和资源都要多,而线程池可以复用已经创建的线程,也可以对线程做一个统一的管理。

2.why?(为什么要用线程池,优点是什么)

优点**(其实就是结合着线程池的几个参数去说就好了!抖音问的线程池的优点没答出来!)**

1. 线程复用,降低资源的消耗:线程的创建和和销毁都需要消耗资源,所以线程池通过设定核心线程数,来让一部分线程一直等待着任务执行。

**2. 控制最大的并发数量:**可以在任务超过最大核心线程数时,会把任务先放在队列里排队等候,如果队列满了则启动非核心线程。

**3. 防止无限制的增加线程,消耗资源:**如果超过了所设定的参数后,会执行拒绝策略,这样做起码不会影响到正在执行的这些任务

3.how?

顶层接口是 Excutor 接口,ThreadPoolExecutor 是这个接口的实现类。

ThreadPoolExecutor 的构造方法里有几个比较重要的参数可供设置,分别是:

  • corePoolSize 核心线程的最大值

    • 核心线程,核心线程会一直存在于线程池中,即使这个线程什么也不干,而非核心线程如果长时间闲置,就会被销毁
  • maximumPoolSize 线程总数的最大值

    • 核心线程的数量 + 非核心线程的数量。
  • keepAliveTime 非核心线程闲置超时时长

    • 当非核心线程处于闲置状态的时间太长的话,就会被销毁。
  • workQueue 阻塞队列类型

    • LinkedBlockingQueue 链式阻塞队列,底层数据结构是链表,默认大小是 int 型最大值,也可以指定其大小。
    • ArrayBlockingQueue 数组阻塞队列,底层数据结构是数组,需要指定队列的大小。
    • SynchronousQueue 同步阻塞队列,长度为 0,实现点对点的生产者消费者模型。
    • DelayQueue 延迟队列,该队列中的元素只有当其指定延迟时间到了,才能够从队列中获取到该元素!
  • handler 拒绝策略

    • AbortPolicy 默认拒绝处理策略,丢弃任务并抛出异常。
    • DiscardPolicy 丢弃新来的任务,但是不抛出异常
    • DiscardOldestPolicy 丢弃最旧的任务
    • CallerRunsPolicy 由调用线程处理该任务

不用解释分别是什么意思,直接说一下工作流程就可以了,每当我线程池要起一个线程去执行任务的时候,会先判断当前运行的线程数是否小于 corePoolSize,如果小于的话,直接让新的线程去执行任务,如果大于的话,判断 workQueue 是否已满,如果未满就把任务放在阻塞队列里,如果阻塞队列满了,则判断当前线程数目是否大于 maximumPoolSize 的大小,如果小于的话,创建新的线程执行任务,如果大于的话,就执行拒绝策略。

四个内置的线程池:

  • newCachedThreadPool:

    • **线程数:**只有非核心,没有核心线程,最大为 int 的最大值
    • **阻塞队列和拒绝策略:**不会触发拒绝策略,原因是非核心线程最大值是 int 最大值。
    • **特点:**即使没有任务进来,也不会占用很多资源。当需要执行很多短时间的任务时,cachedThreadPool 的复用率比较高,会显著的提高性能,而且线程会在 60s 后回收,
  • newFixedThreadPool:

    • **线程数:**只有核心线程,没有非核心线程,在用的时候需要设置大小
    • 阻塞队列和拒绝策略:不会触发拒绝策略,因为用的是链式阻塞队列。
    • **特点:**由于线程不会被回收,会一直卡在阻塞,所有在没有任务的情况下,占用资源更多。
  • newSingleThreadPool:

    • **有且仅有一个核心线程:**也不会创建非核心线程
    • 阻塞队列和拒绝策略:也不会触发拒绝策略,用的也是链式阻塞队列。
    • **特点:**任务按照先来先执行的顺序执行
  • newScheduledThreadPool:定长线程池,支持周期性任务执行。

4.you?

你在项目里用过吗?那特么必须用过啊。。。谁还没用过一个线程池呢?

2.7 j.u.c atomic 原子类

1.what?(原子类是啥)

我理解原子性其实就是一组一旦开始执行,就不会被打断的操作。在多线程中,就是一个操作一旦开始,就不会被其他线程干扰。

Java 的原子类就是具有原子操作的类,其实我觉得这个 juc 包下的原子类并不是什么新的东西,只是利用 CAS 的原理实现了一些具有原子操作的类供我们使用而已。

juc 下的原子类有哪几种?

只说前两种就行了!后面就说没用过!

  • 基本类型:

    • AtomicInteger:整形

    • AtomicLong:长整型

    • AtomicBoolean:布尔型

  • 数组类型

    • AtomicIntegerArray:整形数组
    • AtomicLongArray:长整型数组
    • AtomicReferenceArray:引用类型数组原子类
  • 引用类型

    • AtomicReference:引用类型原子类
    • AtomicStampedReference:从Java1.5开始JDK的atomic包里提供这个类以解决ABA问题。这个类的 compareAndSet 方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
  • AtomicMarkableReference:原子更新带有标记位的引用类型

2.how?(怎么实现的线程安全)

以 AtomicInteger 为例来讲解,他常用的方法有下面这个几个

public final int get() //获取当前的值
public final int getAndSet(int newValue)//获取当前的值,并设置新的值
public final int getAndIncrement()//获取当前的值,并自增
public final int getAndDecrement() //获取当前的值,并自减
public final int getAndAdd(int delta) //获取当前的值,并加上预期的值
boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update)

alt alt 主要就是用了一个 Unsafe 类的一些 native 方法来实现的 CAS 操作,比如 getAndIncrement() 其实就是调了 Unsafe 类的 compareAndSwapInt() 方法来实现的。

其实这个方法就是 CAS 的一个典型的实现,可以看成是 CAS 的三个操作数,地址(o + offset) + 旧值(v) + 新值(v + delta),如果内存位置的值预期原值相匹配,那么将内存里的值修改为新值 。否则,继续自旋的判断直到修改成功。

另外 value 被 volatile 修饰,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。

3.ABA 问题原子类是如何解决的?

AtomicStampedReference 其实就是加了一个版本控制,如果有修改的话其实对应的版本也会更改,所以在交换的时候多了一个条件而已,不仅值需要相等,版本号(也就是引用)也必须相等。

从 Java1.5 开始 JDK 的 atomic 包里提供这个类以解决 ABA 问题。这个类的 compareAndSet 方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

#Java##银行##晒一晒我的offer#
全部评论
乐观锁和悲观锁的解释是不是相反了
点赞 回复 分享
发布于 2024-05-23 19:53 黑龙江
呀呀呀,鸡姐又更新了呢,好努力呀,夸夸夸😍😍😍
点赞 回复 分享
发布于 2023-11-10 15:07 安徽

相关推荐

水墨不写bug:疑似没有上过大学
点赞 评论 收藏
分享
不愿透露姓名的神秘牛友
06-19 17:02
鼠鼠深知pdd的强度很大,但是现在没有大厂offer,只有一些不知名小厂我是拒绝等秋招呢,还是接下?求大家帮忙判断一下!
水中水之下水道的鼠鼠:接了再说,不图转正的话混个实习经历也不错
投递拼多多集团-PDD等公司10个岗位 >
点赞 评论 收藏
分享
评论
4
9
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务