小言_互联网的博客

(Java实习生)每日10道面试题打卡——Java多线程篇 (二)

483人阅读  评论(0)
  • 临近秋招,备战暑期实习,祝大家每天进步亿点点!Day12
  • 本篇总结的是 Java 多线程 相关的面试题,后续会每日更新~


1、是否了解volatile关键字?能否解释下它和synchronized有什么区别?

线程安全行:

线程安全性包括三个方面,①可见性,②原子性,③ 有序性

volatile特性

通俗来说就是,线程A对一个volatile变量的修改,对于其它线程来说是可见的,即线程每次获取volatile变量的值都是最新的。

二者对比

  • volatile是轻量级的synchronized,保证了共享变量的可见性,被volatile关键字修饰的变量,如果值发生了变化,其他线程立刻可见,避免出现脏读现象!
  • volatile轻量级,只能修饰变量。synchronized重量级,还可修饰方法
  • volatile只能保证数据的可见性,不能用来同步,因为多个线程并发访问volatile修饰的变量不会阻塞
    synchronized不仅保证可见性,而且还保证原子性,因为,只有获得了锁的线程才能进入临界区,从而保证临界区中的所有语句都全部执行。多个线程争抢synchronized锁对象时,会出现阻塞
  • volatile:保证可见性,但是不能保证原子性
  • synchronized:保证可见性,也保证原子性

使用场景:

对变量的写操作不依赖当前值,如多线程下执行a++,是无法通过volatile保证结果原子性的;

volatile int i = 0;并且大量线程调用i的自增操作,那么 volatile 可以保证变量的安全吗?

不可以保证!,volatile不能保证变量操作的原子性!

  • 自增操作包括三个步骤,分别是:读取,加一,写入,由于这三个子操作的原子性不能被保证,那么n个线程总共调用ni++的操作后,最后的i的值并不是大家想的n,而是一个比n小的数!

  • 解释

    • 比如A线程执行自增操作,刚读取到i的初始值0,然后就被阻塞了!
    • B线程现在开始执行,还是读取到i的初始值0,执行自增操作,此时i的值为1
    • 然后A线程阻塞结束,对刚才拿到的0执行加1与写入操作,执行成功后,i的值被写成1了!
    • 我们预期输出2,可是输出的是1,输出比预期小!
  • 代码实例:

    public class VolatileTest {
         
        public volatile int i = 0;
     
        public void increase() {
         
            i++;
        }
     
        public static void main(String args[]) throws InterruptedException {
         
            List<Thread> threadList = new ArrayList<>();
            VolatileTest test = new VolatileTest();
            for (int j = 0; j < 10000; j++) {
         
                Thread thread = new Thread(new Runnable() {
         
                    @Override
                    public void run() {
         
                        test.increase();
                    }
                });
                thread.start();
                threadList.add(thread);
            }
     
            // 等待所有线程执行完毕
            for (Thread thread : threadList) {
         
                thread.join();
            }
            System.out.print(test.i);// 输出9995
        }
    }
    

    总结:

    volatile不需要加锁,因此不会造成线程的阻塞,而且比synchronized更轻量级,而synchronized可能导致线程的阻塞volatile由于禁止了指令重排,所以JVM相关的优化没了,效率会偏弱!

JAVA内存模型简称 JMM:

JMM规定所有的变量存在在主内存,每个线程有自己的工作内存,线程对变量的操作都在工作内存中进行,
不能直接对主内存就行操作。

使用volatile修饰变量,每次读取前必须从主内存属性最新的值,每次写入需要立刻写到主内存中,
volatile关键字修修饰的变量随时看到的自己的最新值,假如线程1对变量v进行修改,那么线程2是可以马上看见!


2、volatile可以避免指令重排,能否解释下什么是指令重排?

  • 指令重排序分两类:
    • 编译器重排序
    • 运行时重排序

JVM在编译java代码或者CPU执行JVM字节码时,对现有的指令进行重新排序,主要目的是为了优化运行效率(不改变程序结果的前提)

int a = 3;     // step:1
int b = 4;     // step:2
int c =5;      // step:3 
int h = a*b*c; // step:4

定义顺序: 1,2,3,4
计算顺序: 1,3,2,42,1,3,4 结果都是一样的
  • 虽然指令重排序可以提高执行效率,但是多线程上可能会影响结果,有什么解决办法?
  • 解决办法:内存屏障(了解即可~)
    • 内存屏障是屏障指令,使CPU对屏障指令之前和之后的内存操作执行结果的一种约束!

扩展:现行发生原则happens-before(了解即可~)

volatile 的内存可见性就体现了先行发生原则!


3、介绍一下并发编程三要素?

  • 原子性
  • 有序性
  • 可见性

3.1 原子性

  • 一个不可再被分割的最小颗粒,原子性指的是一个或多个操作要么全部执行成功要么全部执行失败,期间不能被中断,也不存在上下文切换,线程切换会带来原子性的问题!
int num = 1; // 原子操作
num++;       // 非原子操作,从主内存读取num到线程工作内存,进行+1,再把num写回到主内存, 
		    // 除非用原子类:即,java.util.concurrent.atomic里的原子变量类

// 解决办法是可以用synchronized 或 Lock(比如ReentrantLock) 来把这个多步操作“变成”原子操作
// 这里不能使用volatile,前面有说到:对变量的写操作不依赖当前值,如多线程下执行a++,是无法通过volatile保证结果原子性的
public class XdTest {
   
    
    // 方式1:使用原子类
    // AtomicInteger  num = 0;// 这种方式的话++操作就可以保证原子性了,而不需要再加锁了
    private int num = 0;
    
    // 方式2:使用lock,每个对象都是有锁,只有获得这个锁才可以进行对应的操作
    Lock lock = new ReentrantLock();
    public  void add1(){
   
        lock.lock();
        try {
   
            num++;
        }finally {
   
            lock.unlock();
        }
    }
    
    // 方式3:使用synchronized,和上述是一个操作,这个是保证方法被锁住而已,上述的是代码块被锁住
    public synchronized void add2(){
   
        num++;
    }
}

解决核心思想:把一个方法或者代码块看做一个整体,保证是一个不可分割的整体

3.2 有序性

  • 程序执行的顺序按照代码的先后顺序执行,因为处理器可能会对指令进行重排序JVM在编译java代码或者CPU执行JVM字节码时,对现有的指令进行重新排序,主要目的是优化运行效率(不改变程序结果的前提)
int a = 3;     // step:1
int b = 4;     // step:2
int c =5;      // step:3 
int h = a*b*c; // step:4

定义顺序: 1,2,3,4
计算顺序: 1,3,2,42,1,3,4 结果都是一样的(单线程情况下)
指令重排序可以提高执行效率,但是多线程上可能会影响结果!

假如下面的场景:

// 线程1
before();// 处理初始化工作,处理完成后才可以正式运行下面的run方法
flag = true; // 标记资源处理好了,如果资源没处理好,此时程序就可能出现问题
// 线程2
while(flag){
   
    run(); // 执行核心业务代码
}

// -----------------指令重排序后,导致顺序换了,程序出现问题,且难排查-----------------

// 线程1
flag = true; // 标记资源处理好了,如果资源没处理好,此时程序就可能出现问题
// 线程2
while(flag){
   
    run(); // 执行核心业务代码
}
before();// 处理初始化工作,处理完成后才可以正式运行下面的run方法

3.3 可见性

  • 一个线程A对共享变量的修改,另一个线程B能够立刻看到!
// 线程 A 执行
int num = 0;
// 线程 A 执行
num++;
// 线程 B 执行
System.out.print("num的值:" + num);

线程A执行 i++ 后再执行线程B,线程B可能有2个结果,可能是01

因为i++ 在线程A中执行运算,并没有立刻更新到主内存当中,而线程B就去主内存当中读取并打印,此时打印的就是0;也可能线程A执行完成更新到主内存了,线程B的值是1

所以需要保证线程的可见性:
synchronized、lock 和 volatile 都能够保证线程可见性

volatile 保证线程可见性案例:使用Volatile关键字的案例分析


4、Java里面有哪些锁?分别介绍一下?

① 乐观锁/悲观锁

  • 悲观锁:
    • 当线程去操作数据的时候,总认为别的线程会去修改数据,所以它每次拿数据的时候总会上锁,别的线程去拿数据的时候就会阻塞,比如synchronized
  • 乐观锁:
    • 每次去拿数据的时候都认为别人不会修改,更新的时候会判断是别人是否回去更新数据,通过版本来判断,如果数据被修改了就拒绝更新,比如CAS是乐观锁,但严格来说并不是锁,通过原子性来保证数据的同步,比如说数据库的乐观锁,通过版本控制来实现,CAS不会保证线程同步,乐观的认为在数据更新期间没有其他线程影响
  • 小结:悲观锁适合写操作多的场景,乐观锁适合读操作多的场景,乐观锁的吞吐量会比悲观锁大!

② 公平锁/非公平锁

  • 公平锁:
    • 指多个线程按照申请锁的顺序来获取锁,简单来说 如果一个线程组里,能保证每个线程都能拿到锁 比如ReentrantLock(底层是同步队列FIFO: First Input First Output来实现)
  • 非公平锁:
    • 获取锁的方式是随机获取的,保证不了每个线程都能拿到锁,也就是存在有线程饿死,一直拿不到锁,比如synchronized、ReentrantLock
  • 小结:非公平锁性能高于公平锁,更能重复利用CPU的时间。ReentrantLock中可以通过构造方法指定是否为公平锁,默认为非公平锁!synchronized无法指定为公平锁,一直都是非公平锁。

③ 可重入锁/不可重入锁

  • 可重入锁:
    • 也叫递归锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁。一个线程获取锁之后再尝试获取锁时会自动获取锁,可重入锁的优点是避免死锁。
  • 不可重入锁:
    • 若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会获取不到被阻塞
  • 小结:可重入锁能一定程度的避免死锁 synchronized、ReentrantLock都是可重入锁

④ 独占锁/共享锁

  • 独享锁,是指锁一次只能被一个线程持有。

    • 也叫X锁/排它锁/写锁/独享锁:该锁每一次只能被一个线程所持有,加锁后任何线程试图再次加锁的线程会被阻塞,直到当前线程解锁。例子:如果 线程A 对 data1 加上排他锁后,则其他线程不能再对 data1 加任何类型的锁,获得独享锁的线程即能读数据又能修改数据!
  • 共享锁,是指锁一次可以被多个线程持有。

    • 也叫S锁/读锁,能查看数据,但无法修改和删除数据的一种锁,加锁后其它用户可以并发读取、查询数据,但不能修改,增加,删除数据,该锁可被多个线程所持有,用于资源数据共享!

ReentrantLock和synchronized都是独享锁,ReadWriteLock的读锁是共享锁,写锁是独享锁

⑤ 互斥锁/读写锁

与独享锁/共享锁的概念差不多,是独享锁/共享锁的具体实现。

ReentrantLock和synchronized都是互斥锁,ReadWriteLock是读写锁

⑥ 自旋锁

  • 自旋锁:
    • 一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环,任何时刻最多只能有一个执行单元获得锁。
    • 不会发生线程状态的切换,一直处于用户态,减少了线程上下文切换的消耗,缺点是循环会消耗CPU。
  • 常见的自旋锁:TicketLock,CLHLock,MSCLock

⑦ 死锁

  • 死锁:
    • 两个或两个以上的线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法让程序进行下去!

下面三种是Jvm为了提高锁的获取与释放效率而做的优化 针对Synchronized的锁升级,锁的状态是通过对象监视器在对象头中的字段来表明,是不可逆的过程

  • 偏向锁:
    • 一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,获取锁的代价更低!
  • 轻量级锁:
    • 当锁是偏向锁的时候,被其他线程访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,但不会阻塞,且性能会高点!
  • 重量级锁:
    • 当锁为轻量级锁的时候,其他线程虽然是自旋,但自旋不会一直循环下去,当自旋一定次数的时候且还没有获取到锁,就会进入阻塞,该锁升级为重量级锁,重量级锁会让其他申请的线程进入阻塞,性能也会降低!

5、介绍下你对synchronized的理解?

源码分析文章参考:java同步系列之synchronized解析

  • synchronized 是解决线程安全的问题,常用在同步普通方法、静态方法、代码块中使用!
  • synchronized 非公平、可重入锁!
  • 每个对象有一个锁和一个等待队列,锁只能被一个线程持有,其他需要锁的线程需要阻塞等待。锁被释放后,对象会从队列中取出一个并唤醒,唤醒哪个线程是不确定的,不保证公平性

6、解释下什么是CAS?以及ABA问题?

这里推荐大家看一下这篇文章:Java并发基石CAS原理以及ABA问题,从简单 CAS的入门使用到分析原理!


7、ReentrantLock和synchronized的差别?

  • ReentrantLock和synchronized都是独占锁,可重入锁,悲观锁
  • synchronized
    • 1、java内置关键字
    • 2、无法判断是否获取锁的状态只能是非公平锁
    • 3、加锁解锁的过程是隐式的,用户不用手动操作,优点是操作简单但显得不够灵活
    • 4、一般并发场景使用足够、可以放在被递归执行的方法上,且不用担心线程最后能否正确释放锁
  • ReentrantLock
    • 1、是个Lock接口的实现类
    • 2、可以判断是否获取到锁,可以为公平锁也可以是非公平锁(默认)
    • 3、需要手动加锁和解锁,且解锁的操作尽量要放在finally代码块中,保证线程正确释放锁
    • 5、创建的时候通过传进参数true 创建公平锁,如果传入的是false或没传参数则创建的是非公平锁
    • 6、底层是AQS的 stateFIFO 队列来控制加锁。

8、请问并发编程三要素是什么?在 Java 程序中怎么保证多线程的运行安全?

  • 原子性:原子性是指一个或多个操作要么全部执行成功要么全部执行失败。(synchronized关键字可以保证原子性)
  • 可见性一个线程对共享变量的修改,另一个线程能够立刻看到。(synchronizedvolatile关键字都可以保证可见性)
  • 有序性程序执行的顺序按照代码的先后顺序执行。(处理器编译优化可能会对指令进行重排序)

Java 中解决线程安全的方式:

  • Atomic开头的原子类、、synchronized关键字、Lock 锁,可以解决原子性问题。
  • synchronized关键字、volatile关键字、Lock 锁,可以解决可见性问题。
  • volatile 关键字修饰的变量,可以禁用指令重排,禁止的是加volatile 关键字变量之前的代码重排序,保证有序性问题。

9、什么是线程上下文切换?

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

概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换


10、守护线程和用户线程有什么区别呢?

  • 守护线程:运行在后台,为其他前台线程服务。一旦所有用户线程都结束运行,守护线程会随之一起结束运行。
  • 用户线程:运行在前台,用于执行具体的任务,如程序的主线程

可以通过thread.setDaemon(true)方式将一个线程设置为守护线程。

注意①:必须在thread.start()之前设置,否则会跑出一个 IllegalThreadStateException异常。不能把正在运行的常规线程设置为守护线程。

注意②:由于守护线程的终止是自身无法控制的,因此千万不要把 IO、File 等重要操作逻辑分配给它;因为这些操作会随时可能抛出异常,守护线程也会随之结束!


总结的面试题也挺费时间的,文章会不定时更新,有时候一天多更新几篇,如果帮助您复习巩固了知识点,还请三连支持一下,后续会亿点点的更新!


为了帮助更多小白从零进阶 Java 工程师,从CSDN官方那边搞来了一套 《Java 工程师学习成长知识图谱》,尺寸 870mm x 560mm,展开后有一张办公桌大小,也可以折叠成一本书的尺寸,有兴趣的小伙伴可以了解一下,当然,不管怎样博主的文章一直都是免费的~


转载:https://blog.csdn.net/weixin_43591980/article/details/117396827
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场