Java面试题超详细整理《多线程篇》

为什么要使用并发编程?

  • 提升多核CPU的利用率: 在一个多核CPU的主机上,我们可以创建多个线程,将多个线程分配给不同的CPU去执行,每个CPU执行一个线程,这样就提高了CPU的使用效率。
  • 方便进行业务拆分: 面对复杂业务模型,可以对业务模块进行拆分,从而提升响应速度。而进行拆分时可以使用多线程技术来完成。

优点:提高了程序的执行效率,一定情况下可以提高程序运行速度。
缺点:可能出现内存泄漏、上下文切换、线程安全、死锁等问题。


并发编程三要素是什么?

  • 原子性:一个操作是不可分割的,要么全部执行成功要么全部执行失败。
  • 可见性:一个线程对共享变量的值进行修改后,另一个线程能够立刻看到修改的值。(synchronized,volatile)
  • 有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行指令重排)

指令重排:虚拟机在进行代码编时,对于那些改变顺序后不会对最终结果造成影响的代码,虚拟机不一定会按照我们写的代码顺序来执行,有可能将他们重排序。实际上对有些代码进行重排序后,虽然对变量的值没有造成影响,但有可能出现线程安全问题。


并行、并发、串行的区别

  • 串行:串行在时间上不可能发生重叠,前一个任务没有执行结束,下一个任务就不会开始
  • 并行:并行在时间上是重叠的,两个任务在同一时刻互不干扰的同时执行
  • 并发:多个任务在同一个 CPU 核上,按细分的时间片轮流(交替)执行,同一时间点,只有一个任务执行,任务交替执行

什么是线程和进程?两者的区别是什么?

  • 进程:程序的⼀次执⾏过程,是系统运⾏程序的基本单位。进程是动态的,系统运⾏⼀个程序即是⼀个进程从创建,运⾏到消亡的过程。
  • 线程:线程是进程划分成的更⼩的运⾏单位,进程中的一个执行任务,⼀个进程在其执⾏的过程中可以产⽣多个线程,在程序里独立执行。一个进程至少有一个线程,一个进程可以运行多个线程,多个线程可共享进程中的数据。

进程与线程的区别:

  • 根本区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位
  • 资源开销:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,所以系统在产生⼀个线程,或是在各个线程之间作切换⼯作时,开销要⽐进程小得多,所以线程也被称为轻量级进程
  • 内存分配:进程与进程之间的地址空间和资源是相互独立的,同一进程的线程共享本进程的地址空间和资源,线程之间可能会相互影响。
  • 影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃有可能导致整个进程都死掉。所以多进程要比多线程健壮。
  • 执行过程:每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行

对线程安全的理解

当多个线程访问一个对象时,如果不用进行额外的同步控制或其他的协调操作,调用这个对象的行为都可以获得正确的结果,我们就说这个对象是线程安全的 。

在每个进程的内存空间中都会有一块特殊的公共区域,通常称为堆(内存)。进程内的所有线程都可以访问到该区域,这就是造成线程安全的潜在原因。

目前主流操作系统都是多任务的。为了保证安全,每个进程只能访问分配给自己的内存空间,而不能访问别的进程的,这是由操作系统保障的

如何保证线程安全?

方法一:使用安全类,比如 java.util.concurrent 下的类,使用原子类AtomicInteger
方法二:使用JVM提供的自动锁 synchronized。
方法三:使用JDK提供的手动锁 Lock。


synchronized关键字的作用

synchronized 它可以把任意一个非 NULL 的对象当作锁。他属于独占式的悲观锁,同时属于可重入锁。

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

三种使用方式:

  • 修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
  • 修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
  • 修饰代码块: 指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

总结: synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。尽量不要使用 synchronized(String a) 因为JVM中,字符串常量池具有缓存功能

构造方法可以使用 synchronized 关键字修饰么?
构造方法不能使用 synchronized 关键字修饰,构造方法本身就属于线程安全的,不存在同步的构造方法一说。


volatile 关键字的作用

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

  • 对于加了 volatile关键字的成员变量,在对这个变量进行修改时,全直接将CPU高级缓存中的数据送回到主内存,对这个变量的读取也会直接从主内存中读取,从而保证了可见性
  • 在对 volatile修饰的成员变量进行读写时,会插入内存屏障,而内存屏障可以达到禁止重排序的效果,从而可以保证有序性

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

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

volatile是怎样实现了?

volatile实现内存可见性原理:
一个问题:本地内存和主内存之间的值不一致,导致内存不可见。
在生成汇编代码时会在volatile修饰的共享变量进行写操作的时候会多出Lock前缀的指令。将当前处理器缓存行的数据写回系统内存,这个写回内存的操作会使得其他CPU里缓存了该内存地址的数据无效,当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。这样针对volatile变量通过这样的机制就使得每个线程都能获得该变量的最新值。

volatile实现有序性原理:
为了实现volatile的内存语义,编译器在生成字节码时会通过插入内存屏障来禁止指令重排序。
内存屏障:内存屏障是一种CPU指令,它的作用是对该指令前和指令后的一些操作产生一定的约束,保证一些操作按顺序执行。

什么是CAS?

CAS即Compare And Swap,比较并替换。是一条CPU并发原语,Java中可以通过CAS操作来保证原子性,它的功能是判断内存某个位置的值是否为预期值,如果是则更新为新的值,这个过程是原子的。

CAS并发原语提现在Java语言中就是sun.misc.UnSafe类中的各个方法。调用UnSafe类中的CAS方法,JVM会帮我实现CAS汇编指令。这是一种完全依赖于硬件功能,通过它实现了原子操作。原语的执行必须是连续的,在执行过程中不允许中断,也即是说CAS是一条原子指令,不会造成所谓的数据不一致的问题。

Unsafe:CAS的核心类,Java方法无法直接访问内存,需要通过本地方法native来访问,在Unsafe中所有方法都是native方法,用来直接操作内存,执行相应的任务。

CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。类似于乐观锁。CAS自旋的概率会比较大,从而浪费更多的CPU资源。在这个过程中可能存在ABA问题:
当你获得对象当前数据后,在准备修改为新值前,对象的值被其他线程连续修改了两次(A->B->A),而经过两次修改后,对象的值又恢复为旧值,这样当前线程无法正确判断这个对象是否修改过。

ABA的问题的解决方式:

  • ABA的解决方法也很简单,就是利用版本号。给变量加上一个版本号,每次变量更新的时候就把版本号加1,这样即使E的值从A—>B—>A,版本号也发生了变化,这样就解决了CAS出现的ABA问题。基于CAS的乐观锁也是这个实现原理。
  • JDK1.5时可以利用AtomicStampedReference类来解决这个问题,AtomicStampedReference内部不仅维护了对象值,还维护了一个时间戳。当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须要更新时间戳,对象值和时间戳都必须满足期望值,写入才会成功

自旋:当多个线程同时操作一个共享变量时,只有一个线程可以对变量进行成功更新,其他线程均会失败,但是失败并不会被挂起,进行再次尝试,也就是自旋。Java中的自旋锁就是利用CAS来实现的。


说说 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是一个内置的Java关键字,ReentrantLock是一个类,实现了Lock接口

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

  • 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 所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。

什么是AQS?

AQS的全称是AbstractQueuedSynchronizer,是一个用来构建锁和同步器的框架,像ReentrantLock,Semaphore,FutureTask都是基于AQS实现的。

AQS的工作流程:AQS会维护一个共享资源,当被请求的共享资源空闲,则将请求资源的线程设为有效的工作线程,同时锁定共享资源。如果被请求的资源已经被占用了,AQS就用过队列实现了一套线程阻塞等待以及唤醒时锁分配的机制。
在这里插入图片描述

从图中可以看出AQS维护了一个共享资源和一个FIFO的线程等待队列。这个队列是通过CLH队列实现的,该队列是一个双向队列,有Node结点组成,每个Node结点维护一个prev引用和next引用,这两个引用分别指向自己结点的前驱结点和后继结点,同时AQS还维护两个指针Head和Tail,分别指向队列的头部和尾部。

如何使用AQS自定义同步器?

AQS的资源共享方式:
Exclusive:独占,只有一个线程可以执行,例如ReentrantLock
Share:共享,多个线程可同时执行,如Semaphore/CountDownLatch

AQS的底层使用了模板方法模式,自定义同步器只需要两步:
第一,继承AbstractQueuedSynchronizer,第二,重写以下几种方法:

  • isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
  • tryAcquire(int):独占方式,尝试获取资源。
  • tryRelease(int):独占方式,尝试释放资源。
  • tryAcquireShared(int):共享方式,尝试获取资源。负数表示失败,0表示成功,但无剩余可用资源,正数表示成功并且有剩余资源
  • tryReleaseShared(int):共享方式,尝试释放资源

独占式的ReentrantLock实现方式:,state初始状态为0,表示未锁定状态。A线程进行lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再调用tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。

共享式的CountDownLatch实现方式:任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。

一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。

CopyOnWriteArrayList

我们都知道将ArrayList作为共享变量,在多线程的情况下是不安全的,解决方法是使用Collections中的SynchronizedList方法,或者我们代码中进行加锁,其实还有另一种线程安全的List,就是CopyOnWriteArrayList。

当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。对CopyOnWrite容器进行并发的读的时候,不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,延时更新的策略是通过在写的时候针对的是不同的数据容器来实现的,放弃数据实时性达到数据的最终一致性。

先对CopyOnWriteArrayList进行一个总体概览,它具有三个特点:

  • 线程安全的,多线程环境下可以直接使用,无需加锁;
  • 通过锁 + 数组拷贝 + volatile 关键字保证了线程安全;
  • 每次数组操作,都会把数组拷贝一份出来,在新数组上进行操作,操作成功之后再赋值回去。

CountDownLatch、CyclicBarrier、Semaphore的区别

CountDownLatch和CyclicBarrier都能够实现线程之间的等待,只不过它们侧重点不同;

  • CountDownLatch一般用于某个线程A等待若干个其他线程执行完任务之后,它才执行;
  • 而CyclicBarrier一般用于一组线程互相等待至某个状态,然后这一组线程再同时执行;
  • 另外,CountDownLatch是不能够重用的,而CyclicBarrier是可以重用的。

Semaphore其实和锁有点类似,它一般用于控制对某组资源的访问权限。

CountDownLatch:计数器,允许一个或多个线程等待直到其他线程中执行的一组操作完成同步辅助。可以用于高并发测试,即计数积累了一定数量的线程后再一起执行。
原理:
countDownLatch.countDown(); // 计数器数量-1
countDownLatch.await(); // 线程会被挂起,等待计数器归零,然后再向下执行,可以设置等待时间

import java.util.concurrent.CountDownLatch; // 计数器 
public class CountDownLatchDemo { 
	public static void main(String[] args) throws InterruptedException { 
	    
	    // 总数是6,必须要执行任务的时候,再使用! 
		CountDownLatch countDownLatch = new CountDownLatch(6); 
		for (int i = 1; i <=6 ; i++) {
 			new Thread(()->{ 
 				System.out.println(Thread.currentThread().getName()+" Go out"); 
 				countDownLatch.countDown(); // 数量-1 
 			},String.valueOf(i)).start(); 
 		}
		countDownLatch.await(); // 等待计数器归零,然后再向下执行 
		System.out.println("Close Door"); 
	} 
}

在这里插入图片描述

CyclicBarrier: 加法计数器,一组线程全部等待到达共同点。达不到会一直等待
cyclicBarrier.await(); //用来挂起当前线程,直至所有线程都到达brrier状态再同时执行后续任务;

import java.util.concurrent.BrokenBarrierException; 
import java.util.concurrent.CyclicBarrier; 
public class CyclicBarrierDemo { 
	public static void main(String[] args) { 
		/*** 集齐7颗龙珠召唤神龙 */ 
		CyclicBarrier cyclicBarrier = new CyclicBarrier(7,()->{ 
			System.out.println("召唤神龙成功!"); 
		}); 
		for (int i = 1; i <=7 ; i++) { 
			final int temp = i; // lambda能操作到 i 吗 
			new Thread(()->{ 
				System.out.println(Thread.currentThread().getName()+"收 集"+temp+"个龙珠");
				try {
				cyclicBarrier.await(); // 等待 
				} catch (InterruptedException e) { 
					e.printStackTrace(); 
				} catch (BrokenBarrierException e) { 
					e.printStackTrace(); 
				} 
			}).start(); 
		} 
	} 
}

在这里插入图片描述

Semaphore:一组计数信号量
semaphore.acquire(); //获得,假设如果已经满了,等待,等待被释放为止!
semaphore.release(); //释放,会将当前的信号量释放 + 1,然后唤醒等待的线程!
作用: 多个共享资源互斥的使用!并发限流,控制最大的线程数!

import java.util.concurrent.Semaphore; 
import java.util.concurrent.TimeUnit; 
public class SemaphoreDemo { 
	public static void main(String[] args) { 
		// 线程数量:停车位! 限流! 抢车位!6车---3个停车位置
		Semaphore semaphore = new Semaphore(3); 
		for (int i = 1; i <=6 ; i++) { 
			new Thread(()->{ // acquire() 得到 
				try {
					semaphore.acquire(); 
					System.out.println(Thread.currentThread().getName()+"抢到车 位"); 			
					TimeUnit.SECONDS.sleep(2); 
					System.out.println(Thread.currentThread().getName()+"离开车 位"); 
				} catch (InterruptedException e) { 
					e.printStackTrace(); 
				} finally { 
					semaphore.release(); // release() 释放 
				} 
			},String.valueOf(i)).start(); 
		} 
	} 
}

在这里插入图片描述

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 上执行变成了可能。

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

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

  • 主动让出 CPU,比如调用了 sleep(), wait() 等。
  • 时间片用完,因为操作系统要防止一个线程或者进程长时间占用CPU导致其他线程或者进程饿死。
  • 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞,被终止或结束运行

线程之间如何进行通信

  • 通过共享内存或基于网络通信
  • 如果是基于共享内存进行通信,则需要考虑并发问题,什么时候阻塞,什么时候唤醒
  • 想Java中的wait()、notify()就是阻塞唤醒
  • 通过网络就比较简单,通过网络连接将数据发送给对方,当然也要考虑到并发问题,处理方式就是加锁等方式。

说一下ThreadLocal

ThreadLocal是Java中所提供的线程本地存储机制,可以利用该机制将数据缓存在某个线程内部,该线程可以在任意时刻、任意方法中获取缓存的数据。

ThreadLocal底层是通过 TreadLocalMap来实现的,每个Thread对象中都存在一个 ThreadLocalMap,Map的key为 ThreadLocal对象,Map的 value为需要缓存的值 。

  • ThreadLocalMap由一个个 Entry对象构成 Entry继承自 WeakReference< Threadlocal<?>>,一个 Entry由 Threadlocal对象和 object构成,由此可见,Entry的key是 Threadlocal对象,并且是一个弱引用。当没指向key的强引用后,该key就会被垃圾收集器回收
  • 当执行set方法时, Threadlocal首先会获取当前线程对象,然后获取当前线程的 ThreadLocalMap对象。再以当前 Threadlocal对象为key,将值存储进 Threadlocalmap对象中
  • get方法执行过程类似,Threadloca首先会获取当前线程对象,然后获取当前线程的 ThreadLocalMap对象。再以当前 ThreadLocal对象为key,获取对应的value

由于每条线程均含有各自私有的 ThreadLocalMap容器,这些容器相互独立互不影响,因此不会存在线程安全性问题,从而也无需使用同步机制来保证多条线程访问容器的互斥性。

使用场景:

  • 在进行对象跨层传递的时候,使用ThreadLocal可以避免多次传递,打破层次间的约束
  • 线程间数据隔离
  • 进行事务操作,用于存储线程事务信息
  • 数据库连接,Session会话管理

Spring框架在事务开始时会给当前线程绑定一个 Jdbc Connection,在整个事务过程都是使用该线程定的connection来执行数据库操作,实现了事务的隔离性, spring框架里面就是用的 Threadlocal来实现这种隔离。


ThreadLocal内存泄漏原因,如何避免?

如果在线程池中使用 ThreadLocal会造成内存泄漏,因为当 ThreadLocal对象使用完之后,应该要把设置的key,value也就是Entry对象进行回收,但线程池中的线程不会回收,而线程对象是通过强引用指向 ThreadLocalMap, ThreadLocalMap也是通过强引用指向Entry对象,线程不被回收,Entry对象也就不会被回收,从而出现内存泄漏。

解决办法:

  • 在使用了 ThreadLocal对象之后,手动调用ThreadLocal的 remove方法,手动清除Entry对象
  • 将ThreadLocal变量定义成private static,这样就一直存在ThreadLocal的强引用,也就能保证任何时候都能将通过ThreadLocal的弱引用访问到Entry的value值,进而清除掉。

线程池的原理?

线程池做的工作主要是控制运行的线程的数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量超出数量的线程排队等候,等其它线程执行完毕,再从队列中取出任务来执行。他的主要特点为:线程复用;控制最大并发数;管理线程。

重用存在的线程,减少对象创建销毁的开销,且提高了响应速度;有效的控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞,且可以定时定期执行、单线程、并发数控制,配置任务过多任务后的拒绝策略等功能。

线程池类别:

  • newFixedThreadPool :一个定长线程池,可控制线程最大并发数。
  • newCachedThreadPool:一个可缓存线程池。
  • newSingleThreadExecutor:一个单线程化的线程池,用唯一的工作线程来执行任务。
  • newScheduledThreadPool:一个定长线程池,支持定时/周期性任务执行。

线程池尽量不要使用 Executors 去创建,而是通过 ThreadPoolExecutor的方式去创建,因为Executors创建的线程池底层也是调用 ThreadPoolExecutor,只不过使用不同的参数、队列、拒绝策略等如果使用不当,会造成资源耗尽问题。直接使用ThreadPoolExecutor让使用者更加清楚线程池允许规则,常见参数的使用,避免风险。

主要参数:

  • corePoolSize:核心线程数,默认情况下创建的线程数,默认情况下核心线程会一直存活,是一个常驻线程。
  • maximumPoolSize:线程池维护线程的最大数量,超过将被阻塞!(当核心线程满,且阻塞队列也满时,才会判断当前线程数是否小于最大线程数,才决定是否创建新线程)
  • keepAliveTime:非核心线程的闲置超时时间,超过这个时间就会被回收,直到线程数量等于 corePoolSize。
  • unit:指定 keepAliveTime 的单位,如 TimeUnit.SECONDS、TimeUnit.MILLISECONDS
  • workQueue:线程池中的任务队列,常用的是 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue。
  • threadFactory:创建新线程时使用的工厂
  • handler:RejectedExecutionHandler 是一个接口且只有一个方法,线程池中的数量大于 maximumPoolSize,对拒绝任务的处理策略,默认有 4 种策略:AbortPolicy、CallerRunsPolicy、DiscardOldestPolicy、DiscardPolicy

四种拒绝策略

AbortPolicy:中止策略。默认的拒绝策略,直接抛出 RejectedExecutionException。调用者可以捕获这个异常,然后根据需求编写自己的处理代码。

DiscardPolicy:抛弃策略。什么都不做,直接抛弃被拒绝的任务。

DiscardOldestPolicy:抛弃最老策略。抛弃阻塞队列中最老的任务,相当于就是队列中下一个将要被执行的任务,然后重新提交被拒绝的任务。如果阻塞队列是一个优先队列,那么“抛弃最旧的”策略将导致抛弃优先级最高的任务,因此最好不要将该策略和优先级队列放在一起使用。

CallerRunsPolicy:调用者运行策略。在调用者线程中执行该任务。该策略实现了一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将任务回退到调用者(调用线程池执行任务的主线程),由于执行任务需要一定时间,因此主线程至少在一段时间内不能提交任务,从而使得线程池有时间来处理完正在执行的任务。


线程池中线程复用的原理

  • 线程池将线程和任务讲行解耦,线程是线程,任务是任务,摆脱了之前通过 Thread创建线程时的个线程必须对应一个任务的限制。
  • 在线程池中,同一个线程可以从阻塞队列中不断获取新任务来执行,其核心原理在于线程池对 Thread进行了封装,并不是每次执行任务都会调用 Thread. start()来创建新线程,而是让每个线程去执行一个循环任务,在这个循环任务中不停检查是否有任务需要被执行,如果有则直接执行,也就是调用任务中的run方法,将run方法当成一个普通的方法执行,通过这种方式只使用固定的线程就将所有任务的run方法串联起来。

如果你提交任务时,线程池队列已满,这时会发生什么

  • 如果使用的是无界队列,没关系,继续添加任务到阻塞队列中等待执行
  • 如果使用的是有界队列,任务首先会被添加到队列中,如果队列满了,会根据maximumPoolSize的值增加线程数量,如果增加了线程数量还是处理不过来,队列依然是满的,那么则会使用拒绝策略处理满了的任务,默认是 AbortPolicy。
    在这里插入图片描述

阻塞队列的作用

—般的 队列只能保证作为一个有限长度的缓冲区,如果超出了缓冲长度,就无法保留当前的任务了,阻塞队列 通过阻塞可以保留住当前要继续入队的任务。

阻塞队列可以保证任务队列中没有任务时阻塞获取任务的线程,使得线程进入wait状态,释放CPU资源。
阻塞队列自带阻塞和唤醒的功能,不需要额外处理,无任务执行时线程池利用阻塞队列的take方法挂起,从而维持核心线程的存活、不至于一直占用cpu资源。

为什么先添加队列而不是先创建最大线程?
在创建新线程的时候,是要获取全局锁的,这个时候其它的就得阻塞,影晌了整体效率。 就好比一个企业甲面有10个(core)正式工的名额,最多招10个正式工,要是任务超过正式工人数(task>core)的情况下,工厂领导(线程池)不是首先扩招工人,还是这10人,但是任务可以稍微积压一下,即先放到队列去(代价低)。10个正式工慢慢干,迟早会干完的,要是任务还在继续增加,超过正式工的加班忍耐极限了(队列满了),就的招外包帮忙了(注意是临时工)要是正式工加上外包还是不能完成任务,那新来的任务就会被领导拒绝了(线程池的拒绝策略)


参考文章:
https://gitee.com/SnailClimb/JavaGuide
https://www.bilibili.com/video/BV1zB4y1K7j7
https://csp1999.blog.csdn.net/article/details/117246491

  • 15
    点赞
  • 59
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论
引用\[1\]和\[2\]提供了一些关于Java多线程面试题。这些面试题可以帮助你在面试前复习并提高你的理论知识。其中,引用\[1\]展示了两种创建多线程的方式,一种是继承Thread类,另一种是实现Runnable接口。继承Thread类的方式比较简单,但是限制了类的继承关系,而实现Runnable接口的方式更加灵活。引用\[3\]展示了使用Callable和FutureTask的方式创建多线程,并获取线程执行结果的示例。这种方式可以在多线程执行完毕后获取线程的返回结果。希望这些面试题能够帮助你在面试中展示你的多线程知识和实战能力。 #### 引用[.reference_title] - *1* *3* [java2023多线程面试题](https://blog.csdn.net/weixin_68009402/article/details/130399986)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control_2,239^v3^insert_chatgpt"}} ] [.reference_item] - *2* [Java面试题及答案整理汇总(2023最新版)](https://blog.csdn.net/Design407/article/details/129009269)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control_2,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

龙源lll

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值