JUC面经

文章目录


1、简述一下线程,程序,进程的概念。以及他们之间的关系是什么?

进程是指一个内存中运行的应用程序,每个进程都有自己独立的一块内存空间,一个进程中可以启动多个线程。 线程是比进程更小的执行单位,它是在一个进程中独立的控制流,一个进程可以启动多个线程,每条线程并行执行不同的任务。

程序是含有指令和数据的文件,被存储在磁盘或其他的数据存储设备中,也就是说程序是静态的代码。

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

  1. 来自不同的类;对于sleep()方法,我们首先要知道该方法是属于Thread类中的。而wait()方法,则是属于Object类中的。
  2. 有没有释放锁;sleep()方法导致了程序暂停执行指定的时间,让出cpu该其他线程,但是他的监控状态依然保持者,当指定的时间到了又会自动恢复运行状态。在调用sleep()方法的过程中,线程不会释放对象锁。当调用wait()方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用notify()方法后本线程才进入对象锁定池准备,获取对象锁进入运行态
  3. 使用的范围不同:wait必须使用在同步代码块中,sleep可以在任何地方使用。
  4. 是否需要捕获异常:wait不需要,sleep必须捕获异常。

3、什么是线程池,为什么使用线程池?

线程池:一个管理线程的池子。

  1. 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  2. 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
  3. 提高线程的可管理性。统一管理线程,避免系统创建大量同类线程而导致消耗完的内存。

4、线程池的实现原理是什么?

提交一个任务到线程池中,线程池的处理流程如下:

  1. 判断线程池里的核心线程是否都在执行任务,如果不是(核心线程空闲或者还有核心线程没有被创建)则创建一个新的工作线程来执行任务,如果核心线程都在执行任务,则进入下一个流程。
  2. 线程池判断工作队列是否已满,如果工作队列没有满,则将新提交的任务存储在这个工作队列里,如果工作队列满了,则进入下一个流程。
  3. 判断线程池里的线程是否都处于工作状态,如果没有,则创建一个新的工作线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务。
    在这里插入图片描述

5、线程池的参数有哪些?

ThreadPoolExecutor 的通用构造函数:

public ThreadPoolExecutor(int corePoolSize, 
						  int maximumPoolSize, 
						  long keepAliveTime, 
						  TimeUnit unit, 
						  BlockingQueue<Runnable> workQueue, 
						  ThreadFactory threadFactory, 
						  RejectedExecutionHandler handler
						  );

1、corePoolSize 线程池核心线程大小
线程池中会维护一个最小的线程数量,即使这些线程处理空闲状态,他们也不会被销毁,除非设置了allowCoreThreadTimeOut。这里的最小线程数量即是corePoolSize。任务提交到线程池后,首先会检查当前线程数是否达到了corePoolSize,如果没有达到的话,则会创建一个新线程来处理这个任务。

2、maximumPoolSize 线程池最大线程数量
当前线程数达到corePoolSize后,如果继续有任务被提交到线程池,会将任务缓存到工作队列中。如果队列也已满,则会去创建一个新线程来出来这个处理。线程池不会无限制的去创建新线程,它会有一个最大线程数量的限制,这个数量即由maximunPoolSize指定。

3、keepAliveTime 空闲线程存活时间
一个线程如果处于空闲状态,并且当前的线程数量大于corePoolSize,那么在指定时间后,这个空闲线程会被销毁,这里的指定时间由keepAliveTime来设定。

4、unit 空闲线程存活时间单位
keepAliveTime的计量单位。

5、workQueue 工作队列
新任务被提交后,会先进入到此工作队列中,任务调度时再从队列中取出任务。
jdk中提供了四种工作队列:

  1. ArrayBlockingQueue:基于数组的有界阻塞队列,按先进先出排序。新任务进来后,会放到该队列的队尾,有界的数组可以防止资源耗尽问题。当线程池中线程数量达到corePoolSize后,再有新任务进来,则会将任务放入该队列的队尾,等待被调度。如果队列已经是满的,则创建一个新线程,如果线程数量已经达到maxPoolSize,则会执行拒绝策略。
  2. LinkedBlockingQuene:基于链表的无界阻塞队列(其实最大容量为Interger.MAX),按照先进先出排序。由于该队列的近似无界性,当线程池中线程数量达到corePoolSize后,再有新任务进来,会一直存入该队列,而基本不会去创建新线程直到maxPoolSize(很难达到Interger.MAX这个数),因此使用该工作队列时,参数maxPoolSize其实是不起作用的。
  3. SynchronousQuene:一个不缓存任务的阻塞队列,生产者放入一个任务必须等到消费者取出这个任务。也就是说新任务进来时,不会缓存,而是直接被调度执行该任务,如果没有可用线程,则创建新线程,如果线程数量达到maxPoolSize,则执行拒绝策略。
  4. PriorityBlockingQueue:具有优先级的无界阻塞队列,优先级通过参数Comparator实现。

6、threadFactory 线程工厂
创建一个新线程时使用的工厂,可以用来设定线程名、是否为守护线程等等。

7、handler 拒绝策略
当工作队列中的任务已到达最大限制,并且线程池中的线程数量也达到最大限制,这时如果有新任务提交进来,该如何处理呢。这里的拒绝策略,就是解决这个问题的,jdk中提供了4中拒绝策略:

  1. CallerRunsPolicy:使用此策略,如果添加到线程池失败,那么主线程会自己去执行该任务,不会等待线程池中的线程去执行
  2. AbortPolicy:该策略是线程池的默认策略。使用该策略时,如果线程池队列满了丢掉这个任务并且抛出RejectedExecutionException异常。
  3. DiscardPolicy:该策略下,如果线程池队列满了,会直接丢掉这个任务并且不会有任何异常。
  4. DiscardOldestPolicy:该策略下,如果队列满了,会将最早进入队列的任务删掉腾出空间,再尝试加入队列。因为队列是队尾进,队头出,所以队头元素是最老的,因此每次都是移除对头元素后再尝试入队。

5、线程池的大小如何设置?

如果线程池线程数量太小,当有大量请求需要处理,系统响应比较慢影响体验,甚至会出现任务队列大量堆积任务导致OOM。
如果线程池线程数量过大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换(cpu给线程分配时间片,当线程的cpu时间片用完后保存状态,以便下次继续运行),从 而增加线程的执行时间,影响了整体执行效率。

CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止某些原因导致的任务暂停(线程阻塞,如io操作,等待锁,线程sleep)而带来的影响。一旦某个线程被阻塞,释放了cpu资源,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。

I/O 密集型任务(2N): 系统会用大部分的时间来处理 I/O 操作,而线程等待 I/O 操作会被阻塞,释放cpu资源,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法:最佳线程数 = CPU核心数 * (1/CPU利用率) = CPU核心数 * (1 + (I/O耗 时/CPU耗时)),一般可设置为2N。

6、线程池的类型有哪些?主要适用于什么场景?

常见的线程池有 FixedThreadPool、SingleThreadExecutor、CachedThreadPool 和ScheduledThreadPool。这几个都是 ExecutorService (线程池)实例。

1、FixedThreadPool(创建一个固定的线程池的大小)
固定线程数的线程池。任何时间点,最多只有 nThreads 个线程处于活动状态执行任务。

public static ExecutorService newFixedThreadPool(int nThreads) { 
	return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, 
								  new LinkedBlockingQueue<Runnable>()); 
	}

使用无界队列 LinkedBlockingQueue(队列容量为 Integer.MAX_VALUE),运行中的线程池不会拒绝任务,即不会调用RejectedExecutionHandler.rejectedExecution()方法。maxThreadPoolSize 是无效参数,故将它的值设置为与coreThreadPoolSize 一致。keepAliveTime 也是无效参数,设置为0L,因为此线程池里所有线程都是核心线程,核心线程不会被回收(除非设置了executor.allowCoreThreadTimeOut(true))。

适用场景:适用于处理CPU密集型的任务,确保CPU在长期被工作线程使用的情况下,尽可能的少的分配线程,即适用执行长期的任务。需要注意的是,FixedThreadPool 不会拒绝任务,在任务比较多的时候会导致 OOM。

2、SingleThreadExecutor(单个线程)
只有一个线程的线程池。

public static ExecutionService newSingleThreadExecutor() { 
	return new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, 
							   	  new LinkedBlockingQueue<Runnable>()); 
	} 

使用无界队列 LinkedBlockingQueue。线程池只有一个运行的线程,新来的任务放入工作队列,线程处理完任务就循环从队列里获取任务执行。保证顺序的执行各个任务。

适用场景:适用于串行执行任务的场景,一个任务一个任务地执行。在任务比较多的时候也是会导致OOM。

3、CachedThreadPool(可伸缩的马,遇强则强,遇弱则弱)
根据需要创建新线程的线程池。

public static ExecutorService newCachedThreadPool() { 
	return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, 
								new SynchronousQueue<Runnable>()); 
	}

如果主线程提交任务的速度高于线程处理任务的速度时, CachedThreadPool 会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。使用没有容量的SynchronousQueue作为线程池工作队列,当线程池有空闲线程时,SynchronousQueue.offer(Runnable task) 提交的任务会被空闲线程处理,否则会创建新的线程处理任务。
适用场景:用于并发执行大量短期的小任务。 CachedThreadPool 允许创建的线程数量为Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。

4、newScheduleThreadPool

作用:创建一个线程池,它可安排在给定延迟后运行命令或者定期地执行。

特征:

(1)线程池中具有指定数量的线程,即便是空线程也将保留
(2)可定时或者延迟执行线程活动

1Executors.newScheduledThreadPool(int corePoolSize)// corePoolSize线程的个数 2newScheduledThreadPool(int corePoolSize, ThreadFactory threadFactory)// corePoolSize线程的个数,threadFactory创建线程的工厂

7、线程的生命周期是什么?

线程的生命周期包含5个阶段,包括:新建、就绪、运行、阻塞、销毁

  • 新建:就是刚使用new方法,new出来的线程;
  • 就绪:就是调用的线程的start()方法后,这时候线程处于等待CPU分配资源阶段,谁先抢的CPU资源,谁开始执行;
  • 运行:当就绪的线程被调度并获得CPU资源时,便进入运行状态,run方法定义了线程的操作和功能;
  • 阻塞:在运行状态的时候,可能因为某些原因导致运行状态的线程变成了阻塞状态,比如sleep()、wait()之后线程就处于了阻塞状态,这个时候需要其他机制将处于阻塞状态的线程唤醒,比如调用notify或者notifyAll()方法。唤醒的线程不会立刻执行run方法,它们要再次等待CPU分配资源进入运行状态;
  • 销毁:如果线程正常执行完毕后或线程被提前强制性的终止或出现异常导致结束,那么线程就要被销毁,释放资源;
    在这里插入图片描述

8、创建线程有哪几种方式?

Java中创建线程的方式有以下几种:

  1. 继承Thread类并重写run()方法
    这是Java中最常见的创建线程的方式之一,可以定义一个类继承Thread类,然后重写run()方法,在run()方法中实现需要执行的代码。之后创建该类的实例并调用start()方法启动线程。
  2. 实现Runnable接口
    实现Runnable接口同样可以用于创建线程。需要定义一个类实现Runnable接口,并实现run()方法。创建Thread对象时将该Runnable对象作为参数传入,然后调用start()方法启动线程。
  3. 使用Executor框架
    Java中的Executor框架提供了一种更高层次的线程管理方式。可以创建线程池,然后将需要执行的任务提交给线程池执行。
  4. 实现Callable接口
    Callable接口和Runnable接口类似,都是用于定义需要执行的代码。但是Callable接口可以返回执行结果,而Runnable接口不可以。创建线程时需要将Callable对象作为参数传入。

实现Runnable接口比继承Thread类所具有的优势:

  1. 资源共享,适合多个相同的程序代码的线程去处理同一个资源。
  2. 可以避免java中的单继承的限制。
  3. 线程池只能放入实现Runable或Callable类线程,不能直接放入继承Thread的类。

9、什么是线程死锁?

多个线程同时被阻塞,他们中的一个或者全部都在等待某个资源被释放,由于线程被无限期地阻塞,因此程序不可能正常终止。
如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
在这里插入图片描述

10、线程死锁是怎么产生的,怎么避免?

死锁产生的四个必要条件:只要其中任一条件不成立,死锁就不会发生

  1. 互斥条件:该资源任意一个时刻只由一个线程占用。
  2. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

这是造成死锁必须要达到的4个条件,如果要避免死锁,只需要不满足其中某一个条件即可,而其中前3个条件是作为锁要符合的条件,所以要避免死锁就需要打破第4个条件,不出现循环等待锁的关系。

避免死锁的方法:

  1. 要注意加锁顺序,保证每个线程按同样的顺序进行加锁。
  2. 要注意加锁时限,尝试获取锁的时候加一个超时时间。
  3. 要注意死锁检查,死锁检测是一个更好的死锁预防机制,它主要是针对那些不可能实现按序加锁并且锁超时也不可行的场景。

11、线程run和start的区别?

start()方法被用来启动新创建的线程,而且start()内部调用了run()方法,这和直接调用run()方法的效果不一样。当你调用run()方法的时候,只会是在原来的线程中调用,没有新的线程启动,start()方法才会启动新线程。

12、线程都有哪些方法?

1.start() 与 run()
start(): 启动一个线程,线程之间是没有顺序的,是按CPU分配的时间片来回切换的。

public static void main(String[] args) throws Exception {
   new Thread(()-> {
       for (int i = 0; i < 5; i++) {
           System.out.println(Thread.currentThread().getName() + " " + i);
           try { Thread.sleep(200); } catch (InterruptedException e) { }
       }
   }, "Thread-A").start();

   new Thread(()-> {
       for (int j = 0; j < 5; j++) {
           System.out.println(Thread.currentThread().getName() + " " + j);
           try { Thread.sleep(200); } catch (InterruptedException e) { }
       }
   }, "Thread-B").start();
}

在这里插入图片描述

run(): 调用线程的run方法,就是普通的方法调用,虽然将代码封装到两个线程体中,可以看到线程中打印的线程名字都是main主线程,run()方法用于封装线程的代码,具体要启动一个线程来运行线程体中的代码(run()方法)还是通过start()方法来实现,调用run()方法就是一种顺序编程不是并发编程。

public static void main(String[] args) throws Exception {
    new Thread(()-> {
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName() + " " + i);
            try { Thread.sleep(200); } catch (InterruptedException e) { }
        }
    }, "Thread-A").run();

    new Thread(()-> {
        for (int j = 0; j < 5; j++) {
            System.out.println(Thread.currentThread().getName() + " " + j);
            try { Thread.sleep(200); } catch (InterruptedException e) { }
        }
    }, "Thread-B").run();
}

在这里插入图片描述

2.sleep() 与 interrupt()

public static native void sleep(long millis) throws InterruptedException;
public void interrupt();

sleep(long millis): 睡眠指定时间,程序暂停运行,睡眠期间会让出CPU的执行权,去执行其它线程,同时CPU也会监视睡眠的时间,一旦睡眠时间到就会立刻执行(因为睡眠过程中仍然保留着锁,有锁只要睡眠时间到就能立刻执行)。

sleep(): 睡眠指定时间,即让程序暂停指定时间运行,时间到了会继续执行代码,如果时间未到就要醒需要使用interrupt()来随时唤醒
interrupt(): 唤醒正在睡眠的程序,调用interrupt()方法,会使得sleep()方法抛出InterruptedException异常,当sleep()方法抛出异常就中断了sleep的方法,从而让程序继续运行下去

3.wait() 与 notify()
wait、notify和notifyAll方法是Object类的final native方法。所以这些方法不能被子类重写,Object类是所有类的超类,因此在程序中可以通过this或者super来调用this.wait(), super.wait()。
wait(): 导致线程进入等待阻塞状态,会一直等待直到它被其他线程通过notify()或者notifyAll唤醒。该方法只能在同步方法中调用。如果当前线程不是锁的持有者,该方法抛出一个IllegalMonitorStateException异常。wait(long timeout): 时间到了自动执行,类似于sleep(long millis)。
notify(): 该方法只能在同步方法或同步块内部调用, 随机选择一个(注意:只会通知一个)在该对象上调用wait方法的线程,解除其阻塞状态。
notifyAll(): 唤醒所有的wait对象。

注意:

  1. Object.wait()和Object.notify()和Object.notifyall()必须写在synchronized方法内部或者synchronized块内部。
  2. 让哪个对象等待wait就去通知notify哪个对象,不要让A对象等待,结果却去通知B对象,要操作同一个对象。

4.join()(插队)

join在英语中是“加入”的意思,join()方法要做的事就是,当有新的线程加入时,主线程会进入等待状态,一直到调用join()方法的线程执行结束为止。

5.yield()
交出CPU的执行时间,不会释放锁,让线程进入就绪状态,等待重新获取CPU执行时间,yield就像一个好人似的,当CPU轮到它了,它却说我先不急,先给其他线程执行吧, 此方法很少被使用到,

6.setDaemon(boolean on)

线程分两种:

  • 用户线程:如果主线程main停止掉,不会影响用户线程,用户线程可以继续运行。
  • 守护线程:如果主线程死亡,守护线程如果没有执行完毕也要跟着一块死(就像皇上死了,带刀侍卫也要一块死),GC垃圾回收线程就是守护线程

13、为什么wait, notify 和 notifyAll这些方法不在thread类里面?

明显的原因是JAVA提供的锁是对象级的而不是线程级的,每个对象都有锁,通过线程获得。如果线程需要等待某些锁那么调用对象中的wait()方法就有意义了。如果wait()方法定义在Thread类中,线程正在等待的是哪个锁就不明显了。简单的说,由于wait,notify和notifyAll都是锁级别的操作,所以把他们定义在Object类中因为锁属于对象。

14、synchronized(重点)

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

14.1 synchronized的用法有哪些?

  1. 修饰普通方法:作用于当前对象实例,进入同步代码前要获得当前对象实例的锁。
  2. 修饰静态方法:作用于当前类,进入同步代码前要获得当前类对象的锁,synchronized关键字加到static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁。
  3. 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

14.2 Synchronized的作用有哪些?

  1. 原子性:确保线程互斥的访问同步代码;
  2. 可见性:保证共享变量的修改能够及时可见,其实是通过Java内存模型中的 “对一个变量unlock 操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或assign操作初始化变量值” 来保证的;
  3. 有序性:有效解决重排序问题,即 “一个unlock操作先行发生(happenbefore)于后面对同一个锁的lock操作”。

14.3 synchronized 底层实现原理?

使用javap -c 文件路径 查看字节码文件可以看到
synchronized 同步代码块的实现是通过 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权。

其内部包含一个计数器,当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0 ,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

synchronized 修饰的普通同步方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的是ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
在这里插入图片描述
synchronized 修饰的静态同步方法是使用ACC_STATIC,ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个静态同步方法。表示这是一个类锁。

14.4 synchronized 的偏向锁、轻量级锁和重量级锁

  1. 偏向锁:在锁对象的对象头中记录一下当前获取到该锁的线程的ID,该线程下次如果又来获取该锁就可以直接获取到了。
  2. 轻量级锁:由偏向锁升级而来,当一个线程获取到锁后,此时这把锁就是偏向锁,此时如果有第二个线程来竞争锁,偏向锁就会升级为轻量级锁,之所以叫轻量级锁,是为了和重量级锁区分开,轻量级锁底层是通过自旋来实现的,并不会阻塞线程。
  3. 如果自旋次数过多仍然没有获取到锁,则会升级为重量级锁,重量级锁会导致线程阻塞。

15、详细介绍一下ReentrantLock锁(重要,原理弄明白)

ReentrantLock是Java中提供的一种可重入锁(Reentrant Lock)实现。它相比于synchronized关键字,具有更高的灵活性和可扩展性。

可重入锁指的是同一个线程可以多次获取同一个锁,而不会被锁住。ReentrantLock的实现允许一个线程多次获取锁,而不会造成死锁或其他问题。当同一个线程再次获取锁时,会增加锁的计数器,每次释放锁时,计数器减1。只有当锁计数器为0时,其他线程才能获取到该锁。

ReentrantLock具有以下特点:

  • 可重入性:同一个线程可以多次获取锁,不会造成死锁。
  • 公平锁和非公平锁:ReentrantLock可以创建公平锁和非公平锁。公平锁按照线程请求锁的顺序获取锁,保证线程获取锁的顺序和请求锁的顺序一致。非公平锁则不保证获取锁的顺序。
  • 条件变量:ReentrantLock可以使用条件变量(Condition)实现线程的等待和通知机制。Condition可以在等待队列上挂起和唤醒线程。
  • 中断响应:ReentrantLock支持线程中断响应,可以使用lockInterruptibly()方法获取锁,当线程被中断时,会抛出InterruptedException异常。

ReentrantLock使用时需要注意的是,每次获取锁后需要及时释放锁,否则可能会造成死锁。同时,在使用公平锁时,会影响性能,因为需要维护等待队列。因此,一般情况下,如果不是特别需要公平锁,可以使用默认的非公平锁。

16、ReentrantLock和synchronized区别?

  1. synchronized是一个关键字,ReentrantLock是一个类。
  2. synchronized会自动的加锁与释放锁,ReentrantLock需要手动加锁与释放锁。
  3. synchronized的底层是JVM层面的锁,ReentrantLock是API层面的锁。
  4. synchronized是非公平锁,ReentrantLock默认是非公平锁,可以选择为公平锁。
  5. synchronized锁的是对象,锁信息保存在对象头中,ReentrantLock通过代码中int类型的state标识来标识锁的状态。
  6. . synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生,而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁.

17、ReentrantLock 是如何实现可重入性的?

ReentrantLock 内部自定义了同步器 Sync,在加锁的时候通过 CAS 算法,将线程对象放到一个双向链表中,每次获取锁的时候,检查当前维护的那个线程 ID 和当前请求的线程 ID 是否 一致,如果一致,同步状态加1,表示锁被当前线程获取了多次。

18、Runnable和 Callable有什么区别?

  1. Callable接口方法是call(),Runnable的方法是run();
  2. Callable接口call方法有返回值,支持泛型,Runnable接口run方法无返回值。
  3. Callable接口call()方法允许抛出异常;而Runnable接口run()方法不能继续上抛异常;

19、volatile关键字的作用?(重要)

volatile关键字是Java中的一种修饰符,主要用于修饰变量。它的作用是告诉编译器,被volatile修饰的变量的值可能会发生变化,需要从内存中读取最新的值,而不是从CPU缓存中读取。

具体来说,当一个变量被声明为volatile时,编译器会生成一些额外的指令,以确保变量的值每次读取都是从内存中获取的。同时,在对volatile变量进行写操作时,编译器也会生成额外的指令,以确保写入的值立即被写入到内存中,而不是先写入到CPU缓存中。

volatile关键字的主要作用是解决多线程并发访问共享变量时的可见性问题。在多线程环境中,如果多个线程同时访问一个共享变量,而其中一个线程修改了该变量的值,其他线程可能无法立即感知到该变量的变化,因为每个线程都有自己的CPU缓存。这就可能导致线程之间的数据不一致性和安全性问题。而使用volatile修饰变量,可以保证每个线程读取的是最新的变量值,从而避免数据不一致的问题。

需要注意的是,虽然volatile可以保证多线程并发访问共享变量时的可见性,但它并不能保证原子性。如果需要保证变量的原子性操作,可以使用Java提供的原子类或使用synchronized关键字等其他手段。

20、什么是指令重排?volatile为什么能够禁止指令重排?

20.1 指令重排

指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。Java编译器会在生成指令系列时在适当的位置会插入 内存屏障 指令来禁止处理器重排序。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。对一个volatile字段进行写操作,Java内存模型将在写操作后插入一个写屏障指令,这个指令会把之前的写入值都刷新到内存。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

20.2 禁止指令重排(内存屏障 重点!!!)

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

21、volatile与synchronized的区别?

  1. volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  2. volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的。
  3. volatile仅能实现变量的修改可见性,并不能保证原子性;synchronized则可以保证变量的修改可见性和原子性。
  4. volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
  5. volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。

22、乐观锁的缺点是什么?

乐观锁避免了悲观锁独占对象的现象,提高了并发性能,但它也有缺点:

  1. 乐观锁只能保证一个共享变量的原子操作。如果多一个或几个变量,乐观锁将变得力不从心,但互斥锁能轻易解决,不管对象数量多少及对象颗粒度大小。
  2. 长时间自旋可能导致开销大。假如 CAS 长时间不成功而一直自旋,会 给 CPU 带来很大的开销。
  3. ABA 问题。CAS 的核心思想是通过比对内存值与预期值是否一样而判 断内存值是否被改过,但这个判断逻辑不严谨,假如内存值原来是 A, 后来被一条线程改为 B,最后又被改成了 A,则 CAS 认为此内存值并 没有发生改变,但实际上是有被其他线程改过的,这种情况对依赖过程值的情景的运算结果影响很大。解决的思路是引入版本号,每次变量更新都把版本号加一。

23、ThreadLocal(线程局部变量)

参考博客

23.1 ThreadLocal介绍

ThreadLocal是Java中的一个线程局部变量,它提供了一种在多线程情况下实现线程隔离的机制。简单来说,ThreadLocal为每个线程都创建了一个独立的变量副本,不同线程之间互不干扰,从而实现了线程隔离。

ThreadLocal使用一个Map来存储变量副本,Map的key是ThreadLocal对象本身,value是该线程对应的变量副本。当多个线程访问同一个ThreadLocal变量时,每个线程都会获取到自己的变量副本,从而保证了线程隔离。

ThreadLocal主要有以下特点:

  • 线程隔离:ThreadLocal为每个线程都创建了一个独立的变量副本,不同线程之间互不干扰,从而实现了线程隔离。
  • 空间换时间:使用ThreadLocal可以避免使用synchronized等同步机制,从而提高程序的执行效率。虽然每个线程都需要存储一份变量副本,但是这种空间换时间的做法,在多数情况下是可以接受的。
  • 内存泄露:使用ThreadLocal时需要注意,如果不及时清理变量副本,就会导致内存泄露。因为ThreadLocalMap中的Entry持有ThreadLocal对象的弱引用,而ThreadLocal对象持有变量副本的强引用,如果不及时清理Entry,就会导致ThreadLocal对象无法被回收,从而导致内存泄露。
  • 初始值:ThreadLocal可以设置初始值,如果当前线程没有设置过该变量的值,就会使用初始值。可以通过重写initialValue()方法来设置初始值。

ThreadLocal的应用场景比较广泛,常用于实现线程安全的单例模式、在框架中传递用户信息、在多线程程序中保证变量的线程安全等。但是需要注意,使用ThreadLocal也可能带来一些问题,如内存泄露、数据不一致等,因此需要在使用时慎重考虑。

线程局部变量。当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程。 在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
初始化:建议下面的方法
在这里插入图片描述
在这里插入图片描述

23.2 ThreadLocal源码解析

Thread、ThreadLocal和ThreadLocalMap的关系
在这里插入图片描述
当我们为ThreadLocal变量赋值的时候,实际上就是以当前ThreadLocal实例为key,值为value的Entry往这个ThreadLocalMap中存放。
在这里插入图片描述
在这里插入图片描述

ThreadLocal的底层原理:

  1. ThreadLocal是Java中所提供的线程本地存储机制,可以利⽤该机制将数据缓存在某个线程内部, 该线程可以在任意时刻、任意⽅法中获取缓存的数据
  2. ThreadLocal底层是通过ThreadLocalMap来实现的,每个Thread对象(注意不是ThreadLocal对 象)中都存在⼀个ThreadLocalMap,Map的key为ThreadLocal对象,Map的value为需要缓存的 值
  3. 如果在线程池中使⽤ThreadLocal会造成内存泄漏,因为当ThreadLocal对象使⽤完之后,应该要 把设置的key,value,也就是Entry对象进⾏回收,但线程池中的线程不会回收,⽽线程对象是通过 强引⽤指向ThreadLocalMap,ThreadLocalMap也是通过强引⽤指向Entry对象,线程不被回收, Entry对象也就不会被回收,从⽽出现内存泄漏,解决办法是,在使⽤了ThreadLocal对象之后,⼿ 动调⽤ThreadLocal的remove⽅法,⼿动清楚Entry对象
  4. ThreadLocal经典的应⽤场景就是连接管理(⼀个线程持有⼀个连接,该连接对象可以在不同的⽅ 法之间进⾏传递,线程之间不共享同⼀个连接)

23.3 ThreadLocal的内存泄露问题

什么是内存泄漏?

不再会被使用的对象或者变量占用的内存不能被回收,这就是内存泄漏。

为什么使用弱引用?不用会怎么样
在这里插入图片描述
在这里插入图片描述
由于线程池中会有线程的复用,大量key为null时,也会造成内存泄漏。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

24、并发工具(重要)

24.1 CountDownLatch

CountDownLatch是一个同步计数器(减法计数器),初始化的时候 传入需要计数的线程等待数,可以是需要等待执行完成的线程数,或者大于。
作用:用来协调多个线程之间的同步,或者说起到线程之间的通信(而不是用作互斥的作用)。是一组线程等待其他的线程完成工作以后在执行,相当于加强版join,其中:

await():阻塞当前线程,等待其他线程执行完成,直到计数器计数值减到0countDown():负责计数器的减一。

在这里插入图片描述

24.2 CyclicBarrier(加法计数器)

在这里插入图片描述
在这里插入图片描述

24.3 Semaphore

Semaphore类似于锁,它用于控制同时访问特定资源的线程数量,控制并发线程数。

acquire();获得,假设如果已经满了,等待,等待被释放为止
release();释放,会将当前的信号量释放+1,然后唤醒等待的线程

作用:多个共享资源互斥的使用,并发限流,控制最大的线程数
在这里插入图片描述
在这里插入图片描述

25、锁的分类

25.1 公平锁与非公平锁

公平锁:多个线程申请获取同一资源时,必须按照申请顺序,依次获取资源。

非公平锁:资源释放时,任何线程都有机会获得资源,而不管其申请顺序。

按照线程访问顺序获取对象锁。synchronized 是非公平锁, Lock 默认是非公平锁,可以设置为公平锁,公平锁会影响性能。

public ReentrantLock() { sync = new NonfairSync(); }
public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); }

首先不管是公平锁还是非公平锁,它们的底层实现都会使用AQS来进行排队,它们的区别在于,线程在使用lock()方法加锁时,如果时公平锁,会先检查AQS队列中是否存在线程再排队,如果有线程在排队,则当前线程也进行排队,如果时非公平锁,则不会去检查是否有线程在排队,而是直接竞争锁。
不管是公平锁还是非公平锁,一旦没竞争到锁,都会进行排队,当锁释放时,都是唤醒排在最前面的线程,所以非公平锁只是体现在了线程加锁阶段,而没有体现在线程被唤醒阶段。

25.2 共享式与独占式锁

共享式与独占式的最主要区别在于:同一时刻独占式只能有一个线程获取同步状态,而共享式在同一时刻可以有多个线程获取同步状态。例如读操作可以有多个线程同时进行,而写操作同一时刻只能有一个线程进行写操作,其他操作都会被阻塞。

25.3 悲观锁与乐观锁

悲观锁,每次访问资源都会加锁,执行完同步代码释放锁,synchronized 和 ReentrantLock 属于悲观锁。

乐观锁,不会锁定资源,所有的线程都能访问并修改同一个资源,如果没有冲突就修改成功并退出,否则就会继续循环尝试。乐观锁最常见的实现就是CAS。

乐观锁一般来说有以下2种方式:

  1. 使用数据版本记录机制实现,这是乐观锁最常用的一种实现方式。给数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的version字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。
  2. 使用时间戳。数据库表增加一个字段,字段类型使用时间戳(timestamp),和上面的version类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致则OK,否则就是版本冲突。

适用场景:
悲观锁适合写操作多的场景。
乐观锁适合读操作多的场景,不加锁可以提升读操作的性能。

25.4 可重入锁

指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不会发生死锁,这样的锁就叫做可重入锁。
在这里插入图片描述
简单的来说,在一个synchronized修饰的方法或代码块的内部调用本类的其他synchronized修饰的方法或者代码块时,是永远可以得到锁的。

synchronized和ReentrantLock都是可重入锁。

25.4 自旋锁

自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。

获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁会造成busy-waiting。

它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。
在这里插入图片描述
在这里插入图片描述

26、CAS

26.1 什么是CAS?

CAS全称 Compare And Swap,比较与交换,是乐观锁的主要实现方式。CAS 在不使用锁的情况下实现多线程之间的变量同步。ReentrantLock 内部的 AQS 和原子类内部都使用了 CAS。

CAS算法涉及到三个操作数:

  1. 需要读写的内存值 V。
  2. 进行比较的值 A。
  3. 要写入的新值 B。
    只有当 V 的值等于 A 时,才会使用原子方式用新值B来更新V的值,否则会继续重试直到成功更新值。
    以 AtomicInteger 为例,AtomicInteger 的 getAndIncrement()方法底层就是CAS实现,关键代码是compareAndSwapInt(obj, offset, expect, update) ,其含义就是,如果 obj 内的 value 和 expect 相等,就证明没有其他线程改变过这个变量,那么就更新它为 update ,如果不相等,那就会继续重试直到成功更新值。
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

26.2 CAS存在的问题?

CAS 三大问题:

  1. ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从 A-B-A 变成了 1A-2B-3A 。
    JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,原子更新带有版本号的引用类型。
  2. 循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
  3. 只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
    Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在
    一个对象里来进行CAS操作。

26.3 AtomicInteger 的 getAndIncrement()方法底层实现原理

底层源码
在这里插入图片描述
实现过程
在这里插入图片描述

27、JMM详解

参考博客地址:JMM概述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

28、原子类

28.1 基本类型原子类

使用原子的方式更新基本类型:

  1. AtomicInteger:整型原子类
  2. AtomicLong:长整型原子类
  3. AtomicBoolean :布尔型原子类

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) 
public final void lazySet(int newValue)//最终设置为newValue,使用 lazySet 设置之后可能 导致其他线程在之后的一小段时间内还是可以读到旧的值。

AtomicInteger 类主要利用 CAS (compare and swap) 保证原子操作,从而避免加锁的高开销。

28.2 数组类原子类

使用原子的方式更新数组里的某个元素

  1. AtomicIntegerArray:整形数组原子类
  2. AtomicLongArray:长整形数组原子类
  3. AtomicReferenceArray :引用类型数组原子类

AtomicIntegerArray 类常用方法:

public final int get(int i) //获取 index=i 位置元素的值 
public final int getAndSet(int i, int newValue)//返回 index=i 位置的当前的值,并将其 设置为新值:newValue 
public final int getAndIncrement(int i)//获取 index=i 位置元素的值,并让该位置的元素自增
public final int getAndDecrement(int i) //获取 index=i 位置元素的值,并让该位置的元素自减
public final int getAndAdd(int i, int delta) //获取 index=i 位置元素的值,并加上预期的值
boolean compareAndSet(int i, int expect, int update) //如果输入的数值等于预期值,则以 原子方式将 index=i 位置的元素值设置为输入值(update) 
public final void lazySet(int i, int newValue)//最终 将index=i 位置的元素设置为 newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。

28.3 引用类原子类

  • AtomicReference:引用类型原子类。
  • AtomicStampedReference:带有版本号的引用类型原子类。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
  • AtomicMarkableReference :原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来。

29、什么是线程安全?Vector是一个线程安全类吗?

如果你的代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量 的值也和预期的是一样的,就是线程安全的。一个线程安全的计数器类的同一个实例对象在被多个线程使用的情况下也不会出现计算失误。很显然你可以将集合类分 成两组,线程安全和非线程安全的。Vector 是用同步方法来实现线程安全的, 而和它相似的ArrayList不是线程安全的。

30、CopyOnWriteArrayList的底层原理是怎样的?

  1. ⾸先CopyOnWriteArrayList内部也是⽤过数组来实现的,在向CopyOnWriteArrayList添加元素时,会复制⼀个新的数组,写操作在新数组上进⾏,读操作在原数组上进⾏
  2. 并且,写操作会加锁,防⽌出现并发写⼊丢失数据的问题
  3. 写操作结束之后会把原数组指向新数组
  4. CopyOnWriteArrayList允许在写操作时来读取数据,⼤⼤提⾼了读的性能,因此适合读多写少的应 ⽤场景,但是CopyOnWriteArrayList会⽐较占内存,同时可能读到的数据不是实时最新的数据,所以不适合实时性要求很⾼的场景

31、HashMap的扩容机制原理是什么?

1.7版本以前:

  1. 先⽣成新数组
  2. 遍历⽼数组中的每个位置上的链表上的每个元素
  3. 取每个元素的key,并基于新数组⻓度,计算出每个元素在新数组中的下标
  4. 将元素添加到新数组中去
  5. 所有元素转移完了之后,将新数组赋值给HashMap对象的table属性

1.8版本

  1. 先⽣成新数组
  2. 遍历⽼数组中的每个位置上的链表或红⿊树
  3. 如果是链表,则直接将链表中的每个元素重新计算下标,并添加到新数组中去
  4. 如果是红⿊树,则先遍历红⿊树,先计算出红⿊树中每个元素对应在新数组中的下标位置 。a. 统计每个下标位置的元素个数 b. 如果该位置下的元素个数超过了8,则⽣成⼀个新的红⿊树,并将根节点的添加到新数组的对 应位置 c. 如果该位置下的元素个数没有超过8,那么则⽣成⼀个链表,并将链表的头节点添加到新数组 的对应位置
  5. 所有元素转移完了之后,将新数组赋值给HashMap对象的table属性

32、ConcurrentHashMap的扩容机制是什么?

1.7版本

  1. 1.7版本的ConcurrentHashMap是基于Segment分段实现的
  2. 每个Segment相对于⼀个⼩型的HashMap
  3. 每个Segment内部会进⾏扩容,和HashMap的扩容逻辑类似 6
  4. 先⽣成新的数组,然后转移元素到新数组中
  5. 扩容的判断也是每个Segment内部单独判断的,判断是否超过阈值

1.8版本

  1. 1.8版本的ConcurrentHashMap不再基于Segment实现
  2. 当某个线程进⾏put时,如果发现ConcurrentHashMap正在进⾏扩容那么该线程⼀起进⾏扩容
  3. 如果某个线程put时,发现没有正在进⾏扩容,则将key-value添加到ConcurrentHashMap中,然 后判断是否超过阈值,超过了则进⾏扩容
  4. ConcurrentHashMap是⽀持多个线程同时扩容的
  5. 扩容之前也先⽣成⼀个新的数组
  6. 在转移元素时,先将原数组分组,将每组分给不同的线程来进⾏元素的转移,每个线程负责⼀组或 多组的元素转移⼯作
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值