文章目录
更多相关内容可查看
一. 线程基础
线程和进程
什么是进程
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。
如下图所示,在 Windows 中通过查看任务管理器的方式,我们就可以清楚看到 Windows 当前运行的进程(.exe
文件的运行)。
什么是线程
线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程
并发与并行的区别
- 并发:两个及两个以上的操作在同一 时间段 内执行。
- 并行:两个及两个以上的操作在同一 时刻 执行。
最关键的点是:是否是 同时 执行。
创建线程
继承Thread类
public class CreateThread01 {
static class Mythread extends Thread{
@Override
public void run() {
System.out.println("线程创建的第一种方法");
}
}
public static void main(String[] args) {
new Mythread().start();
}
}
实现Runable接口
public class MyRun implements Runnable{
@Override
public void run() {
System.out.println("线程创建的第二种方法");
}
}
public static void main(String[] args) {
new Thread(new MyRun()).start();
}
实现Callable接口
static class MyCall implements Callable<String>{
@Override
public String call() throws Exception {
System.out.println("线程创建的第四种方法");
return "线程创建的第四种方法";
}
}
public static void main(String[] args) {
Thread thread = new Thread(new FutureTask<String>(new MyCall()));
thread.start();
}
使用线程池
ExecutorService executorService = Executors.newSingleThreadExecutor();
executorService.execute(()->{
System.out.println("使用线程池创建线程");
});
executorService.shutdown();
}
ExecutorService executorService = Executors.newSingleThreadExecutor();
Future<String> submit = executorService.submit(new MyCall());
String s = submit.get();
System.out.println(s);
进阶问题 :
- 实现 Runnable 接口和 Callable 接口的区别?
Runnable 接口 不会返回结果或抛出检查异常,但是 Callable 接口可以接收线程的执行结果。所以,如果任务不需要返回结果或抛出异常推荐使用 Runnable 接口 ,这样代码看起来会更加简洁。- execute()方法和 submit()方法的区别是什么?
execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否
submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Future 的 get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。- 线程的start和run方法有什么区别 ?
1、start方法用来启动相应的线程
2、run方法只是thread的一个普通方法,在主线程里执行
3、需要并行处理的代码放在run方法中,start方法启动线程后自动调用run方法
4、run方法必去是public的访问权限,返回类型为void
线程状态
Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:
- NEW: 初始状态,线程被创建出来但没有被调用
start()
。- RUNNABLE: 运行状态,线程被调用了
start()
等待运行的状态。- BLOCKED :阻塞状态,需要等待锁释放。
- WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
- TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
- TERMINATED:终止状态,表示该线程已经运行完毕。
线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。
等待唤醒机制
在我们多线程开发的时候,有时候会有让一些线程先执行的,这些线程结束后,其他线程再继续执行,列如我们生活中打球,球场的每个人都是一个线程,那一个球员必须先传球给另一个球员,那么另一个球员才能投篮,这就是一个线程的一个动作执行完,另一个线程的动作才能执行
等待方法
wait方法
- wait是Object对象中的一个方法
- 调用wait方法的前提是获得这个对象的锁(synchronized对象锁,如果有多个线程取竞争这个锁,只有一个线程获得锁,其他线程会处于等待队列)
- 使当前执行代码的线程进行等待 . ( 把线程放到等待队列中 )
- 调用wait方法会释放锁
- 满足一定条件会重新尝试获得这个锁,被唤醒的之后不是立即恢复执行,而是进入阻塞队列,竞争
sleep方法
- sleep 是Thread类中提供的方法
- 让当前线程进入休眠,进入"阻塞状态",放弃占用cpu , 让给其他线程使用
- 线程睡眠到期自动苏醒,并返回到可运行状态(就绪),不是运行状态。
唤醒方法
- notify()随机唤醒一个处在等待状态的线程
- notifyAll()唤醒所有处在等待状态的线程
- notify()也要在同步方法或同步块中调用,该方法是用来通知其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 “先来后到”) 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁。
进阶面试问题 ?
wait和sleep的区别
理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间, 唯一的相同点就是都可以让线程放弃执行一段时间
wait方法是Object类提供的方法,需要搭配synchroized锁来使用,调用wait方法会释放锁,等待线程会被其他线程唤醒或者超时自动唤醒,唤醒之后需要再次竞争synchronized锁才能继续执行
sleep是Thread类提供的方法(不一定要搭配synchronized使用),调用sleep方法进入超时等待状态,如果占用锁也不会释放锁,时间到了自动唤醒为什么线程通信的方法 wait(), notify()和 notifyAll()被定义在 Object 类里
因为Java所有类的都继承了Object,Java想让任何对象都可以作为锁,并且 wait(),notify()等方法用于等待对象的锁或者唤醒线程,在 Java 的线程中并没有可供任何对象使用的锁,所以任意对象调用方法一定定义在Object类中。为什么 wait(), notify()和 notifyAll()必须在同步方法或者同步块中被调
当一个线程需要调用对象的 wait()方法的时候,这个线程必须拥有该对象的锁,接着它就会释放这个对象锁并进入等待状态(等待队列)直到其他线程调用这个对象上的 notify()方法。
同样的,当一个线程需要调用对象的 notify()方法时,它会释放这个对象的锁(在执行完锁的代码内容),以便其他在等待的线程就可以得到这个对象锁。由于所有的这些方法都需要线程持有对象的锁,这样就只能通过同步来实现,所以他们只能在同步方法或者同步块中被调用
二. 线程池
线程池作用
所谓池就是容器。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率 , 线程池、数据库连接池、Http 连接池等等都是对这个思想的应用
线程池提供了一种限制和管理资源(包括执行一个任务)的方式。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。
使用线程池的好处:
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
创建线程池
方式一:通过构造方法实现
根据传递的参数不同, 创建适用于不同场景的线程池
方式二:通过 Executor 框架的工具类 Executors 来实现
线程池种类
根据执行器快速创建适用于指定场景的线程池
- Executors.newFixedThreadPool:创建一个固定大小的线程池,可控制并发的线程数,超出的线程会在队列中等待
- Executors.newCachedThreadPool:创建一个可缓存的线程池,若线程数超过处理所需,缓存一段时间后会回收,若线程数不够,则新建线程
- Executors.newSingleThreadExecutor:创建单个线程数的线程池,它可以保证先进先出的执行顺序
- Executors.newScheduledThreadPool:创建一个可以执行延迟任务的线程池
- Executors.newSingleThreadScheduledExecutor:创建一个单线程的可以执行延迟任务的线程池
- Executors.newWorkStealingPool:创建一个抢占式执行的线程池(任务执行顺序不确定)【JDK 1.8 添加】
进阶面试问题
- 你们项目开发过程中使用的是哪种方式创建线程池 ?
根据《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过new ThreadPoolExecutor()
的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险 , 所以我们在企业开发过程中创建线程池一把使用new ThreadPoolExecutor()
手动设置参数的形式创建- 线程池的7个核心参数
corePoolSize
: 核心线程数定义了最小可以同时运行的线程数量。maximumPoolSize
: 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。workQueue
: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。keepAliveTime
:当线程池中的线程数量大于corePoolSize
的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime
才会被回收销毁;unit
:keepAliveTime
参数的时间单位。threadFactory
:executor 创建新线程的时候会用到。handler
:饱和策略。关于饱和策略下面单独介绍一下
Executors 返回线程池对象的弊端如下:
- FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致 OOM。
- CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
线程池任务调度流程
- 当线程池中线程数小于corePoolSize时,新提交任务将创建一个新线程执行任务,即使此时线程池中存在空闲线程。
- 当线程池中线程数达到corePoolSize时,新提交任务将被放入workQueue中,等待线程池中任务调度执行 。
- 当workQueue已满,且maximumPoolSize > corePoolSize时,新提交任务会创建新线程执行任务。
- 当workQueue已满,且提交任务数超过maximumPoolSize,任务由RejectedExecutionHandler(拒绝策略)处理。
- 当线程池中线程数超过corePoolSize,且超过这部分的空闲时间达到keepAliveTime时,回收这些线程。
- 当设置allowCoreThreadTimeOut(true)时,线程池中corePoolSize范围内的线程空闲时间达到keepAliveTime也将回收
阻塞队列 BlockingQueue
在创建线程池的时候我们都需要传入一个 BlockingQueue
,BlockingQueue
是 java.util.concurrent 包提供的用于解决并发生产者 - 消费者问题的最有用的类。
它的特性是在任意时刻只有一个线程可以进行take或者put操作,并且BlockingQueue提供了超时return null的机制。
根据队列的底层实现机制, 队列又可以分为
- 无限(界)队列(unbounded queue ) - 几乎可以无限增长
无界队列, 底层是基于链表实现 , 可以无限延展
- 有限(界)队列 ( bounded queue ) - 定义了最大容量
有界队列底层基于数组实现, 长度固定
JAVA中常用的阻塞队列有
ArrayBlockingQueue
: 一种基于数组的有界队列LinkedBlockingQueue
:一种基于链表的无界队列 , 使用该对象时,除非系统资源耗尽,否则不存在入队失败的情况。该队列会保持无限增长 , 有可能会出现Out Of Memory (内存溢出)PriorityBlockingQueue
(优先任务队列):是一个特殊的无界队列。ArrayBlockingQueue
和LinkedBlockingQueue
都是按照先进先出处理任务,而该类则可以根据任务自身的优先级顺序先后执行。SynchronousQueue
(同步移交队列):它是一个特殊的无界队列,它的名字其实就蕴含了它的特征 同步的队列。为什么说是同步的呢?这里说的并不是多线程的并发问题,而是因为当一个线程往队列中写入一个元素时,写入操作不会立即返回,需要等待另一个线程来将这个元素拿走;同理,当一个读线程做读操作的时候,同样需要一个相匹配的写线程的写操作。这里的 Synchronous 指的就是读线程和写线程需要同步,一个读线程匹配一个写线程。
线程池拒绝策略
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,
ThreadPoolTaskExecutor
定义一些策略:
ThreadPoolExecutor.AbortPolicy
: 抛出RejectedExecutionException
来拒绝新任务的处理ThreadPoolExecutor.CallerRunsPolicy
: 调用执行自己的线程运行任务,也就是直接在调用execute
方法的线程中运行(run
)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略ThreadPoolExecutor.DiscardPolicy
: 不处理新任务,直接丢弃掉ThreadPoolExecutor.DiscardOldestPolicy
: 此策略将丢弃最早的未处理的任务请求
Spring 通过
ThreadPoolTaskExecutor
或者我们直接通过ThreadPoolExecutor
的构造函数创建线程池的时候,当我们不指定RejectedExecutionHandler
饱和策略的话来配置线程池的时候默认使用的是ThreadPoolExecutor.AbortPolicy
。
在默认情况下,ThreadPoolExecutor
将抛出RejectedExecutionException
来拒绝新来的任务 ,这代表我们将丢失对这个任务的处理。
对于可伸缩的应用程序,建议使用ThreadPoolExecutor.CallerRunsPolicy
。当最大池被填满时,此策略为我们提供可伸缩队列。
核心线程工作完了怎么办
- 线程保活: 在一些线程池实现中,即使核心线程完成了当前的任务,它们也可能会被保活一段时间,以便在接收到新任务时可以立即执行,而不需要重新创建线程。这样可以减少线程创建和销毁的开销,并提高响应速度。
- 线程回收: 在一些情况下,线程池可能会将空闲一段时间的核心线程回收,释放系统资源。这样可以节省资源,并在需要时再创建新的核心线程。
- 动态调整线程数量: 一些线程池实现允许动态调整线程数量,根据当前的任务负载和系统资源状况来增加或减少线程数量。当核心线程完成任务后,线程池可能会根据需要增加或减少线程数量,以适应新的任务负载。
- 等待新任务: 如果当前没有新的任务需要执行,并且线程池的配置允许线程等待新任务,那么核心线程可能会进入等待状态,直到有新的任务提交给线程池。
核心线程能销毁吗
- 线程池的允许核心线程超时销毁: 一些线程池实现允许设置核心线程的超时时间。如果在指定的超时时间内,核心线程没有执行任务,线程池可能会将这些空闲的核心线程销毁,以释放系统资源。
- 线程池的动态调整: 一些线程池实现允许动态调整线程数量,包括增加或减少核心线程数量。如果线程池的负载较低,并且配置允许,线程池可能会销毁一些空闲的核心线程以节省资源。
- 空闲线程销毁策略: 在一些情况下,线程池可能会根据一定的策略销毁空闲的核心线程,例如基于时间的空闲线程销毁策略或者基于任务负载的动态销毁策略。
- 线程池关闭: 当线程池关闭时,线程池通常会销毁所有的核心线程,释放所有的资源。线程池关闭时,不再接受新的任务,并尝试终止所有的线程,包括核心线程和非核心线程。
三. 线程安全
线程并发3个特性
多线程并发开发中,要知道什么是多线程的原子性,可见性和有序性,以避免相关的问题产生。
原子性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
可见性
可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
有序性
有序性:程序执行的顺序按照代码的先后顺序执行
虚拟机再进行代码编译时,对于哪些改变顺序之后不会对最终结果造成影响的代码,虚拟机不一定会按照我们些的代码的顺序来执行,有可能将他们重排序。实际上,对于有些代码进行重排序之后,虽然对变量的值没有造成影响,但有可能会出现线程安全问题。
要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。
JAVA内存模型
Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,而JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型。JMM定义了一些语法集,这些语法集映射到Java语言中就是volatile、synchronized等关键字。
JMM线程操作内存的基本的规则:
第一条关于线程与主内存:线程对共享变量的所有操作都必须在自己的工作内存(本地内存)中进行,不能直接从主内存中读写
第二条关于线程间本地内存:不同线程之间无法直接访问其他线程本地内存中的变量,线程间变量值的传递需要经过主内存来完成。
主内存
主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。
本地内存
主要存储当前方法的所有本地变量信息(本地内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的本地内存,即线程中的本地变量对其它线程是不可见的 , 由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。
synchronized
基本概念
synchronized
可以保证方法或者代码块在运行时,同一时刻只有一个线程执行synchronized声明的代码块。还可以保证共享变量的内存可见性。同一时刻只有一个线程执行,这部分代码块的重排序也不会影响其执行结果。也就是说使用了synchronized可以保证并发的原子性,可见性,有序性。
实现原理
synchronized的同步可以解决原子性、可见性和有序性的问题,那是如何实现同步的呢?
Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:
- 普通同步方法,锁是当前实例对象this
- 静态同步方法,锁是当前类的class对象
- 同步方法块,锁是括号里面的对象
当一个线程访问同步代码块时,它首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁。
进阶面试问题
静态synchronized
方法和非静态synchronized
方法之间的调用互斥么?
不互斥!如果一个线程 A 调用一个实例对象的非静态synchronized
方法,而线程 B 需要调用这个实例对象所属类的静态synchronized
方法,是允许的,不会发生互斥现象,因为访问静态synchronized
方法占用的锁是当前类的锁,而访问非静态synchronized
方法占用的锁是当前实例对象锁。
synchronized优化
synchronized是重量级锁,效率不高。但在jdk 1.6中对synchronize的实现进行了各种优化,使得它显得不是那么重了。jdk1.6对锁的实现引入了大量的优化,如自旋锁
、适应性自旋锁
、锁消除
、锁粗化
、偏向锁
、轻量级锁
等技术来减少锁操作的开销。
锁主要存在四中状态,依次是:无锁状态
、偏向锁状态
、轻量级锁状态
、重量级锁状态
,他们会随着竞争的激烈而逐渐升级。
注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
自旋锁
线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。
同时在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。
所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。怎么等待呢?执行一段无意义的循环即可(自旋)
自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。
那么线程在自旋的过程中如何保证 , 数据没有被修改, 如何保证加锁和解锁的原子性呢 ?
CAS(乐观锁)
CAS(compareAndSwap)
也叫比较交换,是一种无锁原子算法,其作用是让CPU
将内存值更新为新值,但是有个条件,内存值必须与期望值相同,并且CAS
操作无需用户态与内核态切换,直接在用户态对内存进行读写操作(意味着不会阻塞/线程上下文切换)
简单总结一下 : CAS的基本思路就是,如果这个地址上的值和期望的值相等,则给其赋予新值,否则不做任何事儿,但是要返回原值是多少。循环CAS就是在一个循环里不断的做cas操作,直到成功为止。
CAS虽然高效地解决了原子操作,但是还是存在一些缺陷的
- ABA问题
- 因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。
- ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。举个通俗点的例子,你倒了一杯水放桌子上,干了点别的事,然后同事把你水喝了又给你重新倒了一杯水,你回来看水还在,拿起来就喝,如果你不管水中间被人喝过,只关心水还在,这就是ABA问题。
- 如果你是一个讲卫生讲文明的小伙子,不但关心水在不在,还要在你离开的时候水被人动过没有,因为你是程序员,所以就想起了放了张纸在旁边,写上初始值0,别人喝水前麻烦先做个累加才能喝水。
- 循环时间长开销大
自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
- 只能保证一个共享变量的原子操作
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。
Jdk中CAS
运用
在JDK中提供了很多的原子类 , 这些原子类的实现就使用CAS
- 更新基本类型类:AtomicBoolean,AtomicInteger,AtomicLong
- 更新数组类:AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray
- 更新引用类型:AtomicReference,AtomicMarkableReference,AtomicStampedReference
适应自旋锁
JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?
线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。
反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。
锁消除
为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。
如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。
我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?
我们虽然没有显示使用锁,但是我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。
锁粗化
在使用同步锁的时候,需要让同步块的作用范围尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
在大多数的情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。
锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁
JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作
偏向锁
偏向锁是针对于一个线程而言的,线程获得锁之后就不会再有解锁等操作了,这样可以省略很多开销。假如有两个线程来竞争该锁话,那么偏向锁就失效了,进而升级成轻量级锁了。
为什么要这样做呢?因为经验表明,其实大部分情况下,都会是同一个线程进入同一块同步代码块的。这也是为什么会有偏向锁出现的原因。
轻量级锁
是指当前锁处于偏向锁状态的时候,被多个线程所访问,偏向锁就会升级为轻量级锁,但只有一个线程能获得锁用使用权,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能
重量锁
当前锁处于轻量级锁状态的时候,被多个线程所访问时,但只有一个线程能获得锁用使用权,其他线程会通过自旋的形式尝试获取锁,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁
synchronized原理
JVM锁的原理主要是基于对象头实现的 , 每个Java对象都有对象头。如果是⾮数组类型,则⽤2个字宽(64位/128位)来存储对象头,如果是数组,则会⽤3个字宽来存储对象头。
在32位处理器中,⼀个字宽是32位;在64位虚拟机中,⼀个字宽是64位。 8字节
对象头的内容如下表 。
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode或锁信息等 |
32/64bit | Class Metadata Access | 存储到对象类型数据的指针 |
32/64bit | Array length | 数组的长度(如果是数组) |
Mark Work的格式如下所示。
锁状态 | 61bit | 1bit是否是偏向锁? | 2bit 锁标志位 |
---|---|---|---|
无锁 | 0 | 01 | |
偏向锁 | 线程ID | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 此时这一位不用于标识偏向锁 | 00 |
重量级锁 | 指向互斥量(重量级锁)的指针 | 此时这一位不用于标识偏向锁 | 10 |
GC标记 | 此时这一位不用于标识偏向锁 | 11 |
可以看到,当对象状态为偏向锁时, Mark Word 存储的是偏向的线程ID;当状态为轻量级锁时, Mark Word 存储的是指向线程栈中 Lock Record 的指针;当状态为重量级锁时, Mark Word 为指向堆中的monitor对象的指针 。
有关Java对象头的知识,参考 https://www.cnblogs.com/makai/p/12466541.html
JVM升级原理
简单点来说,JVM中锁的原理如下:
在Java对象的对象头上,有一个锁的标记,比如,第一个线程执行程序时,检查Java对象头中的锁标记,发现Java对象头中的锁标记为未加锁状态,于是为Java对象进行了加锁操作,将对象头中的锁标记设置为锁定状态。第二个线程执行同样的程序时,也会检查Java对象头中的锁标记,此时会发现Java对象头中的锁标记的状态为锁定状态。于是,第二个线程会进入相应的阻塞队列中进行等待
volatile关键字
虽然JVM对synchronized
对它做了很多优化,但是它还是一个重量级的锁。
而volatile则是轻量级的synchronized。
如果一个变量使用volatile,则它比使用synchronized的成本更加低,因为它不会引起线程上下文的切换和调度
通俗点讲就是说一个变量如果用volatile修饰了,则Java可以确保所有线程看到这个变量的值是一致的,如果某个线程对volatile修饰的共享变量进行更新,那么其他线程可以立马看到这个更新,这就是内存可见性。
volatile使用起来非常简单 , 在一个变量前面加上volatile即可 !
public class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
public static void main(String[] args) {
Singleton.getInstance();
}
}
内存可见性
volatile实现内存可见性的过程
线程写volatile变量的过程:
- 改变线程本地内存中Volatile变量副本的值;
- 将改变后的副本的值从本地内存刷新到主内存
线程读volatile变量的过程:
- 从主内存中读取volatile变量的最新值到线程的本地内存中
- 从本地内存中读取volatile变量的副本
Volatile实现内存可见性原理:
写操作时,通过在写操作指令后加入一条store屏障指令,让本地内存中变量的值能够刷新到主内存中
读操作时,通过在读操作前加入一条load屏障指令,及时读取到变量在主内存的值
PS: 内存屏障(Memory Barrier)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序
原子性的问题
虽然Volatile 关键字可以让变量在多个线程之间可见,但是Volatile不具备原子性。
解决方案:
- 使用synchronized
- 使用ReentrantLock(可重入锁)
- 使用AtomicInteger(原子操作)
ThreadLocal
ThreadLocal直译为线程局部变量。其主要作用就是实现线程本地存储功能,通过线程实现本地资源隔离,解决多线程并发场景下线程安全问题。
针对ThreadLocal而言,由于其适合隔离、线程本地存储等特性,因此天然的适合一些Web应用场景 , 例如 :
- 存储全局用户登录信息
- 存储数据库连接,以及Session等信息
- Spring事务处理方案
- 分布式事务全局事务ID传递
ThreadLocal原理
每一个Thread对象实例中都维护了ThreadLocalMap
对象,对象本质存储了一组以ThreadLocal为key , 以本地线程包含变量为value的K-V键值对。
在ThreadLocalMap内部还维护了一个Entry静态内部类,该类继承了WeakReference(弱引用),并指定其所引用的泛型类为ThreadLocal类型。Entry是一个键值对结构,使用ThreadLocal类型对象作为引用的key
WeakReference : 弱引用 , 被弱引用关联的对象只能生存到下一次垃圾回收发生为止。
如果发生垃圾收集,无论内存空间是否有空间,都会回收掉被弱引用关联的对象。
注意事项
ThreadLocal提供了便利的同时当然也需要注意在使用过程中的一些细节问题。
异步调用
ThreadLocal默认情况下不会进行子线程对父线程变量的传递性,也就是说子线程和父线程之间的数据也是隔离的 , 在开启异步线程的时候需要注意这一点
线程池问题
线程池中线程调用使用ThreadLocal 需要注意,由于线程池中对线程管理都是采用线程复用的方法,在线程池中线程非常难结束甚至于永远不会结束,这将意味着线程持续的时间将不可预测。另外重复使用可能导致ThreadLocal对象未被清理,在ThreadLocalMap中进行值操作时被覆盖,获取到旧值
内存泄露(无用对象不能够被及时清理)
ThreadLocal对象不仅提供了get、set方法,还提供了remove方法。
ThreadLocal中主要的存储单元Entry类继承了WeakReference,该类的引用在虚拟机进行GC时会被进行清理,但是对于value如果是强引用类型,就需要进行手动remove,避免value的内存泄露
标签:精简版,synchronized,对象,2024,线程,内存,自旋,多线程,方法 From: https://blog.csdn.net/Aaaaaaatwl/article/details/139736050进阶面试问题
- 你们项目中哪里使用了ThreadLocal ?
我们项目中主要使用ThreadLocal保存用户登录信息 , 方便在业务处理过程中获取到登录用户信息进行处理
对于程序执行过程中的一些提示信息 , 存在在ThreadLocal中, 向客户端响应的时候, 通过拦截器后置通知, 从ThreadLocal中获取提示信息返回- 你们中使用ThreadLocal 有没有遇到过什么问题 ? 怎么解决的 ?
之前出现过内存泄露问题 , 程序刚刚启动的时候能够正常访问 , 但是运行一段时间之后, 程序的运行效率就很低, 用户发送请求需要阻塞很长时间 , 之前我们以为是服务器硬件问题, 加了配置之后还是这样的 , 后来经过内存诊断工具 发现是出现了内存泄露 , 经过排查发现是因为我们项目在使用ThreadLocal过程中没有及时清理线程中的数据导致的 , 后来在拦截器请求完成通知中, 清楚TheadLocal的数据就好了