1.并发和并行有什么区别?
并行时多核CPU上的多任务处理,多个任务在同一时间真正同时执行
并发是单核CPU上的多任务处理,多个任务在同一时间段内交替执行,通过时间片轮转实现交替执行,用于解决IO密集型任务的瓶颈。
如何理解线程安全?
如果一段代码块或者一个方法被多个线程同时执行,还能够正确地处理共享数据,那么这段代码或者这个方法就是线程安全的。
1.原子性:一个操作要么完全执行,要么完全不执行,不会出现中间状态
可以通过同步关键字synchronized或原子操作,如AtomicInteger来保证原子性
2.可见性:当一个线程修改了共享变量,其他线程能够立即看到变化
可以通过volatile关键字来保证可见性
3.有序性:要确保线程不会因为死锁、饥饿、活锁等问题导致无法继续执行
2.线程和进程的区别?
进程说简单点就是我们在电脑上启动的一个个应用。它是操作系统分配资源的最小单位。
线程是进程中的独立执行单元。多个线程可以共享同一个进程的资源,如内存;每个内存都有自己独立的栈和寄存器
协程
协程是比线程更轻量级的并发单元,可以在单线程中实现并发执行,由开发者显式调度
协程是在用户态进行调度的,避免了线程切换时的内核态开销。
Java自身是不支持协程的,我们可以使用Quasar、Kotlin等框架来实现协程
线程之间是如何进行通信的?
原则上可以通过消息传递和共享内存两种方式来实现。Java采用的是共享内存的并发模型。
这个模型被称为Java内存模型,简称JMM。决定了一个线程对共享变量的写入,何时对另外一个线程可见。当然,本地内存是JMM的一个抽象概念,并不是真实存在的。
共享变量存储在主内存中,每个线程的私有本地内存,存储的是中国共享变量的副本。
3.线程的几种创建方式
第一种需要重写父类Thread的run()方法,并且调用start()方法启动线程
这种方法的缺点是,如果ThreadTask已经继承了另外一个类,就不能再继承Thread类了。
第二种需要重写Runnable接口的run()方法,并将实现类的对象作为参数传递给Thread对象的构造方法,最后调用start()方法启动线程。
这种方法避免了Java的单继承机制,并且更符合面向对象的编程思想,因为Runnable接口将任务代码和线程控制的代码解耦了。
第三种方法需要重写Callable接口call()方法,然后创建FutureTask对象,参数为Callable实现类的对象;紧接着创建Thread对象,参数为DutureTask对象,最后调用start()方法启动线程。
这种方法的优点是可以获取线程的执行结果
启动一个Java程序,里面有哪些线程?
首先是main线程,这是程序之心的入口
然后是垃圾回收线程,它是一个后台线程,负责回收不再使用的对象。
还有编译器线程,比如JIT,负责把一部分热点代码编译后放到codeCache中。
4.调用start方法会执行run方法,那怎么不直接调用run方法?
调用start()会创建一个新的线程,并异步执行run()方法中的代码
直接调用run()方法只是一个普通的同步方法调用,所有代码都在当前线程中执行,不会创建新线程。没有新的线程创建,也就达不到多线程并发的目的。
5.线程中常见的调度方法
6.线程的状态
new:线程被创建但未启动
runnable:线程处于就绪或正在运行状态
blocked:线程被阻塞
waiting:线程等待其他线程的通知或中断
timed_waiting:线程会等待一段时间,超时后会自动恢复
terminated:线程执行完毕,生命周期结束
也就是说,线程的生命周期可以分为五个主要阶段:新建、就绪、运行、阻塞和终止。线程在运行过程中会根据状态的变化在这些阶段之间切换。
状态 |
说明 |
---|---|
NEW | 当线程被创建后,如通过new Thread() ,它处于新建状态。此时,线程已经被分配了必要的资源,但还没有开始执行。 |
RUNNABLE | 当调用线程的start() 方法后,线程进入可运行状态。在这个状态下,线程可能正在运行也可能正在等待获取 CPU 时间片,具体取决于线程调度器的调度策略。 |
BLOCKED | 线程在试图获取一个锁以进入同步块/方法时,如果锁被其他线程持有,线程将进入阻塞状态,直到它获取到锁。 |
WAITING | 线程进入等待状态是因为调用了如下方法之一:Object.wait() 或LockSupport.park() 。在等待状态下,线程需要其他线程显式地唤醒,否则不会自动执行。 |
TIME_WAITING | 当线程调用带有超时参数的方法时,如Thread.sleep(long millis) 、Object.wait(long timeout) 或LockSupport.parkNanos() ,它将进入超时等待状态。线程在指定的等待时间过后会自动返回可运行状态。 |
TERMINATED | 当线程的run() 方法执行完毕后,或者因为一个未捕获的异常终止了执行,线程进入终止状态。一旦线程终止,它的生命周期结束,不能再被重新启动。 |
怎么强制终止线程?
1.调用线程的interrupt()方法,请求终止线程
2.在线程run()方法中检查中断状态,如果线程被中断,就退出线程
7.什么是线程上下文切换?
线程上下文切换是指CPU从一个线程切换到另一个线程执行时的过程
在线程切换的过程中,CPU需要保存当前线程的执行状态,并加载下一个线程的上下文
之所以要这样,是因为CPU在同一时刻只能执行一个线程,为了实现多线程并发执行,需要不断地在多个线程之间切换
为了让用户感觉多个线程是在同时执行的,CPU资源的分配采用了时间片轮转的方式,线程在时间片占用CPU执行任务。当线程使用完时间片后,就会让出CPU让其他线程占用
线程可以被多核调度?
多核处理器提供了并行执行多个线程的能力。每个核心可以独立执行一个或多个线程,操作系统的任务调度器会根据策略和算法,决定哪个线程合适再哪个和核心上运行。
8.守护线程
守护线程是一种特殊的线程,它的作用是为其他线程提供服务。
Java中的线程分为两类,一种是守护线程,一种是用户线程。
JVM启动时会调用main方法,main方法所在的线程就是一个用户线程。在JVM内部,同时还启动了很多守护线程,比如垃圾回收线程。
守护线程和用户线程有什么区别呢?
当最后一个非守护线程结束时,JVM会正常退出,不管当前是否存在守护线程,也就是说守护线程是否结束并不影响JVM退出。
换而言之,只要有一个用户线程还没结束,正常情况下JVM就不会退出
9.线程间有哪些通信方式?
线程之间传递信息的方式有多种,比如说使用volatile和synchronized关键字共享对象、使用wait()和notify()方法实现生产者-消费者模式、使用Exchanger进行数据交换、使用Condition实现线程间的协调等
volatile和synchronized的使用方式
多个线程可以通过volatile和synchronized关键字访问和修改同一个对象,从而实现信息的传递
关键字volatile可以用来修饰成员变量,告知程序任何对该变量的访问均需要从共享内存中获取,并同步刷新回共享内存,保证所有线程对变量访问的可见性
关键字synchronized可以修饰方法,或者同步代码块,确保多个线程在同一时刻只有一个线程在执行方法或代码块
wait()和notify()方法的使用方式
一个线程调用共享对象的wait()方法时,它会进入该对象的等待池,释放已经持有的锁,进入等待状态
一个线程调用notify()方法时,它会唤醒在该对象等待池中等待的一个线程,使其进入锁池,等待获取锁
Condition也提供了类似的方法,await()负责阻塞、signal()和signalAll()负责通知
通常和锁ReentrantLock一起使用,为线程提供了一种等待某个条件成真的机制,并允许其他线程在该条件变化时通知等待线程。
Exchanger的使用方式
Exchanger是一个同步点,可以在两个线程之间交换数据。一个线程调用exchange()方法,将数据传递给另一个线程,同时接受另一个线程的数据。
CompletableFuture的使用方式
Java8引入的一个类,支持异步编程,允许线程在完成计算后将结果传递给其他线程
10.sleep和wait的区别
sleep会让当前线程休眠,不需要获取对象锁,属于Thread类的方法
wait会让获得对象锁的线程等待,要提前获得对象锁,属于Object类的方法
1.sleep()方法属于Thread类,wait()方法属于Object类
2.如果一个线程在持有某个对象锁时调用了sleep方法,它在睡眠期间仍然会持有这个锁
而当线程执行wait方法时,它会释放持有的对象锁,因此其他线程也有机会获取该对象的锁
3.sleep()方法可以在任何地方被调用
wait()方法必须在同步代码块或同步方法中被调用,这是因为调用wait()方法的前提是当前线程必须持有对象的锁。
4.调用sleep方法后,线程会在指定的时间内暂停。当指定时间结束后,线程会进入就绪状态,等待CPU调度再次执行
调用wait方法后,线程会进入等待状态,知道有其他线程在同一对象上调用notify或notifyAll方法,线程才会转变为就绪态,准备再次获得CPU的执行权
11.怎么保证线程安全
线程安全是指在并发环境下,多个线程访问共享资源时,程序能够正确地执行,而不会出现数据不一致地问题。
为了保证线程安全
可以使用sychronized关键字对方法加锁,对代码块加锁。线程在执行同步方法、同步代码块时,会获取类锁或者对象锁,其他线程就会阻塞并等待锁
如果需要更细粒度地锁,可以使用RenntLocak并发重入锁
如果需要保证变量的内存可见性,可以使用volatile关键字
对于简单的原子变量操作,还可以使用Atomic原子类
对于线程独立的数据,可以使用ThreadLocal来为每个线程提供专属的变量副本
对于需要并发容器的地方,可以使用ConcurrentHashMap、CopyOnWriteArrayList等
线程安全的使用场景
单例模式
在多线程环境下,如果多个线程同时尝试创建实例,单例类必须确保只创建一个实例,并提供一个全局访问点
饿汉式是一种比较直接的实现方式,它通过在类加载时就立即初始化单例对象来保证线程安全
懒汉式单例则在第一次使用时初始化单例对象,这种方式需要使用双重检查锁来确保线程安全,volatile关键字用来保证可见性,sychronized关键字用来保证同步
Hashtable的底层数据结构
与 HashMap 类似,Hashtable 的底层数据结构也是一个数组加上链表的方式,然后通过 synchronized 加锁来保证线程安全。
12.ThreadLocal是什么
ThreadLocal是一种用于实现线程局部变量的工具类。它允许每个线程都拥有自己的独立副本,从而实现线程隔离。
使用ThreadLocal的步骤:
1.创建ThreadLocal
2.设置ThreadLocal的值
3.获取ThreadLocal的值
4.删除ThreadLocal的值
ThreadLocal的使用场景
1.在Web应用中,可以使用ThreadLocal存储用户会话信息,这样每个线程在处理用户请求时都能方便地访问当前用户地会话信息
2.在数据库操作中,可以使用ThreadLocal存储数据库连接对象,每个线程有自己独立地数据库连接,从而避免了多线程竞争同一数据库连接地问题
3.在格式化操作中,例如日期格式化,可以使用ThreadLocal存储SimpleDateFormate实例,避免多线程共享同一实例导致的线程安全问题
ThreadLocal的优点
每个线程访问的变量副本都是独立的,避免了共享变量引起的线程安全问题
由于ThreadLocal实现了变量的线程独占,使得变量不需要同步处理,因此能够避免资源竞争
13.怎么使用的ThreadLocal
存储用户信息
用户每次登陆访问接口,都会在请求头中携带一个token,在控制层可以根据这个token,解析出用户的基本信息
假如在服务层和持久层也要用到用户信息,就可以在控制层拦截请求把用户信息存入ThreadLocal
这样我们在任意一个地方,都可以取出Threal中存储的用户信息
很多其他场景的cookie、session等数据隔离都可以通过ThreadLocal去实现
14.ThreadLocal怎么实现的
每个线程维护一个Map,key为ThreadLocal对象,value为想要实现线程隔离的对象
1.通过ThreadLocal的set方法将对象存入Map
2.通过ThreadLocal的get方法从Map中取出对象
3.Map的大小由ThreadLocal对象的多少决定
什么是弱引用、强引用
强引用最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收
软引用表示一个对象处于有用且非必须状态,如果一个对象处于软引用,在内存空间足够的情况下,GC机制并不会回收它,而在内存空间不足时,则会在OOM异常出现之间对其进行回收。(缓存)
弱引用表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收。同样的,因为GC线程优先级较低,所以弱引用也并不是会被立刻回收。(缓存级别更差)虚引用表示一个对象处于无用的状态。在任何时候都有可能被垃圾回收。虚引用的使用必须和引用队列Reference Queue联合使用
15.ThreadLocal的内存泄漏问题
ThreadLocalMap的key是弱引用,但是value却是强引用
如果一个线程一直在运行,并且value一直指向某个强引用对象,那么这个对象就不会被回收,从而导致内存泄漏
如何解决内存泄漏问题
使用完ThreadLocal后,及时调用remove()方法释放内存空间。
remove()会调用ThreadLocalMap的remove方法遍历哈希表,找到key等于当前ThreadLocal的Entry,找到后会调用Entry的clear方法,将Entry的value设置为null
然后执行expungeStaleEntry()方法,清楚key为null的Entry
为什么key要设计为弱引用
弱引用的好处是,当内存不足时,JVM可以及时回收掉弱引用的对象。在下一次访问ThreadLocalMap时,java会自动清理那些键为null的entry,这个过程会在执行get()、set()、remove()时触发
ThreadLocal的改进方案
在 JDK 20 Early-Access Build 28 版本中,出现了 ThreadLocal 的改进方案,即 ScopedValue
。
还有 Netty 中的 FastThreadLocal,它是 Netty 对 ThreadLocal 的优化,内部维护了一个索引常量 index,每次创建 FastThreadLocal 中都会自动+1,用来取代 hash 冲突带来的损耗,用空间换时间。
以及阿里的 TransmittableThreadLocal,不仅实现了子线程可以继承父线程 ThreadLocal 的功能,并且还可以跨线程池传递值。
16.ThreadLocalMap的源码
ThreadLocalMap虽然被叫做Map,但它并没有实现Map接口,是一个简单的线性探测哈希表
底层的数据结构也是数组,数组中的每个元素是一个Entry对象,Entry对象继承了WeakReference,key是ThreadLocal对象,value是线程局部变量
当调用ThreadLocal.set(value)时,会将value存入ThreadLocalMap
set方法是ThreadLocalMap的核心方法,通过key的哈希码与数组长度取模,计算出key在数组中的位置,这一点和HashMap的实现类似
threadLocalHashCode的计算有点东西,每创建一个ThreadLocal对象,它就会新增一个黄金分割心数,可以让哈希码分布的非常均匀
当调用ThreadLocal.get()时,会调用ThreadLocalMap的getEntry()方法,根据key的哈希码找到对应的线程局部变量
当调用ThreadLocal.remove()时,会调用ThreadLocalMap的remove()方法,根据key的哈希码找到对应的线程局部变量,将其清除,防止内存泄漏
17.ThreadLocalMap怎么解决Hash冲突
开放定址法
如果计算得到的槽位i已经被占用,ThreadLocalMap会采用开放地址法中的线性探测来寻找下一个空闲槽位:
如果 i 位置被占用,尝试 i+1。
如果 i+1 也被占用,继续探测 i+2,直到找到一个空位。
如果到达数组末尾,则回到数组头部,继续寻找空位。
为什么不用HashMap的拉链法?
ThreadLocalMap设计的目的是存储线程私有数据,不会有大量的key,所以采用线性探测更节省空间
拉链发还需要单独维护一个链表甚至红黑树,不适合ThreadLocal这种场景
什么是开放地址法
简单来说,就是这个坑被人占了,那就接着去找空着的坑。
18.ThreadLocalMap扩容机制
与HashMap不同,ThreadLocalMap并不会直接在元素数量达到阈值时立即扩容,而是先清理被GC回收的key,然后在填充率达到四分之三时进行扩容
清理时会遍历整个数据,将key为null的Entry清除
阈值threshold的默认值时数组长度的三分之二
扩容时,会将数组长度翻倍,然后重新计算每个Entry的位置,采用线性探测法来寻找新的空位,然后将Entry放入新的数组中
一句话总结:ThreadLocalMap采用的是“先清理再扩容”的策略,扩容时,数组长度翻倍,并重新计算索引,如果发生哈希冲突,采用线性探测法来解决
19.父线程能用ThreadLocal给子线程传值吗
不能
因为ThreadLocal变量存储在每个线程的ThreadLocalMap中,而子线程不会继承父线程的ThreadLocalMap
可以使用InheritableThreadLocal来解决这个问题
子线程在创建时会拷贝父线程的InheritableThrealLocal变量
InheritableThreadLocal的原理
在Thread类的定义中,每个线程都有两个ThreadLocalMap
普通ThreadLocal变量存储在threadLocals中,不会被子线程继承
InheritableThreadLocal变量存储在InheritableThreadLocals中,当创建一个子线程时,Thread的init()方法会检查父线程是否有InheritableThreadLocals,如果有就会拷贝InheritableThreadLocal变量到子线程
20.Java的内存模型
共享变量存储在主内存
中,每个线程都有一个私有的本地内存
,存储了共享变量的副本。
当一个线程更改了本地内存中共享变量的副本,它需要 JVM 刷新到主内存中,以确保其他线程可以看到这些更改。
当一个线程需要读取共享变量时,它一版会从本地内存中读取。如果本地内存中的副本是过时的,JVM 会将主内存中的共享变量最新值刷新到本地内存中。
为什么线程要用自己的内存
线程从主内存拷贝变量到工作内存,可以减少CPU对RAM的开销
每个线程都有自己的变量副本,可以避免多个线程同时修改共享变量导致的数据冲突