写点什么

Java 面试题超详细整理《多线程篇》,mongodb 教程导入外部数据

用户头像
极客good
关注
发布于: 刚刚

Java 提供了 volatile 关键字来保证可见性和有序性(禁止指令重排),volatile 常用于多线程环境下的单次操作(单次读或者单次写)。


  • 对于加了 volatile 关键字的成员变量,在对这个变量进行修改时,全直接将 CPU 高级缓存中的数据送回到主内存,对这个变量的读取也会直接从主内存中读取,从而保证了可见性

  • 在对 volatile 修饰的成员变量进行读写时,会插入内存屏障,而内存屏障可以达到禁止重排序的效果,从而可以保证有序性


volatile 可以和 CAS 结合,来保证原子性。


讲一下 JMM(Java 内存模型)


在当前的 Java 内存模型下,线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。要解决这个问题,就需要把变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。




说说 synchronized 关键字和 volatile 关键字的区别




synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!


  • volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比 synchronized 关键字要好 。

  • volatile 关键字只能用于变量,而 synchronized 关键字可以修饰方法以及代码块 。

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

  • volatile 关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。




单例模式




双重校验锁实现对象单例(线程安全)


public class Singleton {


private volatile static Singleton uniqueInstance;


private Singleton() {


}


public static Singleton getUniqueInstance() {


//先判断对象是否已经实例过,没有实例化过才进入加锁代码


if (uniqueInstance == null) {


//类对象加锁


synchronized (Singleton.class) {


if (uniqueInstance == null) {


uniqueInstance = new Singleton();


}


}


}


return uniqueInstance;


}


}


uniqueInstance 采用 volatile 关键字修饰的原因: uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:


  • 为 uniqueInstance 分配内存空间

  • 初始化 uniqueInstance

  • 将 uniqueInstance 指向分配的内存地址


但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。


谈谈 synchronized 和 ReentrantLock 的区别




  • synchronized 是一个关键字,ReentrantLock 是一个类,实现了 Lock 接口

  • synchronized 会自动加锁或释放锁,ReentrantLock 需要手动加锁和释放锁

  • synchronized 底层是 JVM 层面的锁,ReentrantLock 是 API 层面的锁

  • synchronized 是非公平锁,ReentrantLock 可以选择公平锁和非公平锁

  • synchronized 锁的是对象,锁信息保存在对象头中,ReentrantLock 锁的线程,通过代码中 int 类型的 state 标识来标识锁的状态

  • 相比 synchronized,ReentrantLock 增加了一些高级功能。(等待可中断、可实现公平锁、可实现选择性通知)


Lock 接口


在 jdk1.5 以后,增加了 juc 并发包且提供了 Lock 接口用来实现锁的功能,它除了提供了与 synchroinzed 关键字类似的同步功能,还提供了比 synchronized 更灵活 api 实现。可以把 Lock 看成是 synchronized 的扩展版,Lock 提供了无条件的、可轮询的(tryLock 方法)、定时的(tryLock 带参方法)、可中断的(lockInterruptibly)、可多条件队列的(newCondition 方法)锁操作。另外 Lock 的实现类基本都支持非公平锁(默认)和公平锁,synchronized 只支持非公平锁。


ReentrantLock


ReentantLock 继承接口 Lock 并实现了接口中定义的方法,他是一种可重入锁,除了能完成 synchronized 所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。


ReadWriteLock 读写锁


**为了提高性能,Java 提供了读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。读写锁分为读锁和写锁,多个读锁不互斥,读锁与写锁互斥,这是由 jvm 自己控制的,你只要上好相应的锁即可。


读锁:如果你的代码只读数据,可以很多人同时读,但不能同时写,那就上读锁


写锁:如果你的代码修改数据,只能有一个人在写,且不能同时读取,那就上写锁


Java 中读写锁有个接口 java.util.concurrent.locks.ReadWriteLock ,也有具体的实现 ReentrantReadWriteLock。**




锁分类



是否锁同步资源:乐观锁、悲观锁

乐观锁:一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。


java 中的乐观锁基本都是通过 CAS 操作实现的,CAS 是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。


悲观锁:一种悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会 block 直到拿到锁。java 中的悲观锁就是 synchronized;

线程是否阻塞:自旋锁

如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。


线程自旋是需要消耗 CPU 的,说白了就是让 CPU 在做无用功,如果一直获取不到锁,那线程也不能一直占用 CPU 自旋做无用功,所以需要设定一个自旋等待的最大时间。如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。


自旋锁的优点:


自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!

线程竞争时是否排队:公平锁、非公平锁

  • 公平锁:锁的分配机制是公平的,加锁前检查是否有排队等待的线程,优先排队等待的线程,先来先得

  • 非公平锁:加锁时不考虑排队等待问题,JVM 按随机、就近原则分配锁的机制则称为不公平锁,非公平锁实际执行的效率要远远超出公平锁(5-10 倍),除非程序有特殊需要,否则最常用非公平锁的分配机制。

线程中的多个流程是否可以获取同一把锁:可重入锁(递归锁)

可重入锁,也叫做递归锁,指的是同一线程外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。在 JAVA 环境下 ReentrantLock 和 synchronized 都是可重入锁。

多线程之间能不能共享同一把锁:共享锁和排他锁

  • 共享锁:共享锁则允许多个线程同时获取锁,并发访问 共享资源,如:ReadWriteLock。共享锁则是一种乐观锁,它放宽了加锁策略,允许多个执行读操作的线程同时访问共享资源。

  • 排他锁:每次只能有一个线程能持有锁,ReentrantLock 就是以独占方式实现的互斥锁。独占锁是一种悲观保守的加锁策略,它避免了读/读冲突,如果某个只读线程获取锁,则其他读线程都只能等待,这种情况下就限制了不必要的并发性,因为读操作并不会影响数据的一致性。

锁状态:无锁、偏向锁、轻量级锁、重量级锁

锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。


**锁升级:随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,


也就是说只能从低到高升级,不会出现锁的降级)。**


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

  • 偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能

  • 轻量级锁:当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

  • 重量级锁:升级为重量级锁时,锁标志的状态值变为“10”,此时 Mark Word 中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。




死锁

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


如何查看线程死锁:通过jstack命令进行查看,jstack 中会显示发生死锁的线程


数据库中查看死锁:


查看是否有表锁:show OPEN TABLES where In_use > 0;


查询进程:show processlist;


查看正在锁的事务:SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS;


查看等待锁的事务:SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS_WAITS;




什么是守护线程?




守护 (Daemon) 线程:运行在后台,为其他前台线程服务。也可以说守护线程是 JVM 中非守护线程的 “佣人”。一旦所有用户线程都结束运行,守护线程会随 JVM 一起结束工作。(GC 垃圾回收线程就是一个经典的守护线程)


应用场景:为其他线程提供服务支持、需要正常且立刻关闭某个线程时


守护线程不能用于访问固有资源,比如读写操作或计算逻辑,因为它在任何时候甚至在一个操作的中间发生中断。




创建线程有哪几种方式?




继承 Thread 类,重写 run 方法:


public class MyThread extends Thread {


@Override


public void run() {


System.out.println(Thread.currentThread().getName() + " run()方法正在执行...");


}


}


实现 Runnable 接口,实现 run 方法:


public class MyRunnable implements Runnable {


@Override


public void run() {


System.out.println(Thread.currentThread().getName() + " run()方法执行中...");


}


}


实现 Callable 接口,实现 call 方法。通过 FutureTask 创建一个线程,获取到线程执行的返回值:


public class MyCallable implements Callable<Integer> {


@Override


public Integer call() {


System.out.println(Thread.currentThread().getName() + " call()方法执行 中...");


return 1;


}


}


使用 Executors 工具类创建线程池,并开启线程。




Thread、Runable 和 Callable 三者区别?




  • Thread 是一个抽象类,只能被继承,而 Runable、Callable 是接口,需要实现接口中的方法。继承 Thread 重写 run()方法,实现 Runable 接口需要实现 run()方法,而 Callable 是需要实现 call()方法。

  • Thread 和 Runable 没有返回值,Callable 有返回值,返回值可以被 Future 拿到。

  • 实现 Runable 接口的类不能直接调用 start()方法,需要 new 一个 Thread 并发该实现类放入 Thread,再通过新建的 Thread 实例来调用 start()方法。实现 Callable 接口的类需要借助 FutureTask (将该实现类放入其中),再将 FutureTask 实例放入 Thread,再通过新建的 Thread 实例来调用 start()方法。获取返回值只需要借助 FutureTask 实例调用 get()方法即可!


什么是 FutureTask?


FutureTask 表示一个异步运算的任务。FutureTask 里面可以传入一个 Callable 的具体实现类,可以对这个异步运算的任务的结果进行等待获取、判断是否已经完成、取消任务等操作。只有当运算完成的时候结果才能取回,如果运算尚未完成 get 方法将会阻塞。一个 FutureTask 对象可以对调用了 Callable 和 Runnable 的对象进行包装,由于 FutureTask 也是 Runnable 接口的实现类,所以 FutureTask 也可以放入线程池中。




线程的 run()和 start()有什么区别?




通过调用 Thread 类的 start()方法来启动一个线程, 这时此线程是处于就绪状态, 并没有运行。这时无需等待 run 方法体代码执行完毕,可以直接继续执行下面的代码。


方法 run()称为线程体,它包含了要执行的这个线程的内容,线程就进入了运行状态,开始运行 run 函数当中的代码。 run 方法运行结束, 此线程终止。然后 CPU 再调度其它线程。


run() 可以重复调用,而 start()只能调用一次。


为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?


如果直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。




线程的状态




线程通常有 5 种状态:新建、就绪、运行、阻塞和死亡状态


  • 新建(new):新创建了一个线程对象。

  • 就绪(runnable):线程对象创建后,当调用线程对象的 start()方法,该线程处于就绪状态,等待被线程调度选中,获取 cpu 的使用权。

  • 运行(running):可运行状态(runnable)的线程获得了 cpu 时间片(timeslice),执行程序代码。

  • 阻塞(block):处于运行状态中的线程由于某种原因,暂时放弃对 CPU 的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才 有机会再次被 CPU 调用以进入到运行状态。


阻塞的情况分三种:


① 等待阻塞(wait->等待对列):运行状态中的线程执行 wait()方法,JVM 会把该线程放入等待队列(waittingqueue)中,使本线程进入到等待阻塞状态;


② 同步阻塞(lock->锁池):线程在获取 synchronized 同步锁失败(因为锁被其它线程所占用),则 JVM 会把该线程放入锁池(lock pool)中,线程会进入同步阻塞状态;


③ 其他阻塞(sleep/join): 通过调用线程的 sleep()或 join()或发出了 I/O 请求时,线程会进入到阻塞状态。当 sleep()状态超时、join()等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入就绪状态。


  • 死亡(dead):线程 run()、main()方法执行结束,或者因异常退出了 run()方法,则该线程结束生命周期。死亡的线程不可再次复生。



由上图可以看出:线程创建之后它将处于 new(新建) 状态,调? start() ?法后开始运?,线程这时候处于 ready(可运?) 状态。可运?状态的线程获得了 CPU 时间?(timeslice)后就处于 running(运?) 状态。




线程基本方法




  • 线程等待 wait():使一个线程处于等待(阻塞)状态,并且释放所持有的对象的锁;只有等待另外线程的通知或被中断才会返回,wait 方法一般用在同步方法或同步代码块中

  • 线程睡眠 sleep():使一个正在运行的线程处于睡眠状态,但不会释放当前占有的锁。是一个静态方法,调用此方法要处理 InterruptedException 异常;

  • 等待其他线程终止 join():线程进入阻塞状态,马上释放 cpu 的执行权,但依然会保留 cpu 的执行资格,所以有可能 cpu 下次进行线程调度还会让这个线程获取到执行权继续执行

  • 线程让步 yield():使当前线程让出 CPU 执行时间片,与其他线程一起重新竞争 CPU 时间片,执行后线程进入阻塞状态,例如在线程 B 种调用线程 A 的 join(),那线程 B 会进入到阻塞队列,直到线程 A 结束或中断线程。

  • 线程唤醒 notify():唤醒一个处于等待状态的线程,当然在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由 JVM 确定唤醒哪个线程,而且与优先级无关;

  • 线程全部唤醒 notityAll():唤醒所有处于等待状态的线程,该方法并不是将对象的锁给所有线程,而是让它们竞争,只有获得锁的线程才能进入就绪状态;




sleep() 和 wait() 有什么区别?




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


  • 类的不同:sleep() 是 Thread 线程类的静态方法,wait() 是 Object 类的方法。

  • 是否释放锁:sleep() 不释放锁;wait() 释放锁,并且会加入到等待队列中。

  • 是否依赖 synchronized 关键字:sleep 不依赖 synchronized 关键字,wait 需要依赖 synchronized 关键字

  • 用途不同:sleep 通常被用于休眠线程;wait 通常被用于线程间交互/通信,

  • 用法不同:sleep() 方法执行完成后,不需要被唤醒,线程会自动苏醒,或者可以使用 wait(longtimeout)超时后线程会自动苏醒。wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。




什么是上下文切换?




*巧妙地利用了时间片轮转的方式, CPU 给每个任务都服务一定的时间,然后把当前任务的状态保存下来,在加载下一任务的状态后,继续服务下一任务,任务的状态保存及再加载, 这段过程就叫做上下文切换。时间片轮转的方式使多个任务在同一颗 CPU 上执行变成了可能。


【一线大厂Java面试题解析+核心总结学习笔记+最新架构讲解视频+实战项目源码讲义】
浏览器打开:qq.cn.hn/FTf 免费领取
复制代码



    上下文:指某一时间点 CPU 寄存器和程序计数器的内容


    几种发生上下文切换的情况:


    • 主动让出 CPU,比如调用了 sleep(), wait() 等。

    • 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。

    • 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞,被终止或结束运行




    线程之间如何进行通信




    • 通过共享内存或基于网络通信

    • 如果是基于共享内存进行通信,则需要考虑并发问题,什么时候阻塞,什么时候唤醒

    • 想 Java 中的 wait()、notify()就是阻塞唤醒

    • 通过网络就比较简单,通过网络连接将数据发送给对方,当然也要考虑到并发问题,处理方式就是加锁等方式。

    用户头像

    极客good

    关注

    还未添加个人签名 2021.03.18 加入

    还未添加个人简介

    评论

    发布
    暂无评论
    Java面试题超详细整理《多线程篇》,mongodb教程导入外部数据