多线程与高并发
# 并发和并行的区别:
并发指的是多个线程同时操作一个资源,而并行则是多个线程同时操作不同的资源,进行不同的事件。
# volatile是什么
概念:volatile是java虚拟机提供的轻量级的同步机制。
主要有三大特性: 1.保证可见性 : 在JAVA规范中是这样定义的:java编程语言允许线程访问共享变量,但是要确保共享变量能被准确和一致地更新。 说人话:简单来说就是,我们知道所有的变量都存储在主内存中,但是线程并不能直接的对主内存中的变量进行操作,而是要将变量拷贝到各自的工作内存中进行操作。 操作完成之后再将数据写回到主内存中,打个比方就是,现在有两个线程同时对Y数据进行操作,A线程修改,B线程读取,当A线程修改完数据写回主内存的时候, B线程会重新去读取新数据。
2.不保证原子性 ---- 可以使用synchronized,也可以使用atomic包 3.禁止指令重排 ---- 避免多线程环境下程序出现乱序执行的现象
PS: 何为指令重排? 计算机在执行程序的时候,会以性能为最主要条件,编译器和处理器就会对指令做一个重排,以使得指令或者说代码的执行效率更高,性能更好,但是在处理器重排时, 也必须要考虑指令之间的数据依赖性,简单来说就是必须先有某个东西,然后才能有下一个东西。在多线程环境下,线程会疯狂的争抢资源,再加上指令重排, 就会导致最终数据的一致性出现问题。
所以在多线程环境下,我们需要禁止指令重排,来保证数据执行的最终一致性。
# 请谈谈JMM
概念:JMM,java内存模型,本身是一种抽象的概念,并不真实存在。 可以说他是一种规范,定义了程序中各个变量,包括实例字段,静态字段和构成数组对象的元素的一个访问方式。
JMM关于同步的规定: 1.线程解锁前,必须把共享变量的值刷新回主内存 2.线程加锁前,必须读取主内存的最新值到自己的工作内存 3.加锁解锁是同一把锁
java内存模型要求: 1.可见性 2.原子性: 保证数据的完整一致性 说人话就是,在某个线程正在做某个具体业务的时候,中间不可以被加塞或者分割,需要整体完整,要么同时成功,要么同时失败。 3.有序性
PS: 由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而java内存模型中规定, 所有的变量都存储在主内存,主内存是共享内存区域。所有线程都可以访问,但线程对变量的操作(读取或是修改等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存, 然后对变量进行操作,操作完成之后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本,因此不同的线程间无法访问对方的工作内存, 线程之间的通信或是操作,都必须通过主内存来完成。
# atomic为什么可以保证原子性
(里面使用了volatile,所以同时也可以保证可见性)
因为底层使用的是CAS -------------- 什么是CAS? 同时类里面有一个变量叫valueOffset,表示的是该数据在内存中的偏移地址,因为unsafe就是根据内存偏移地址来获取数据的。 同时里面的成员变量value使用了volatile修饰,保证了多线程之间的内存可见性,同时也禁止了指令重排的发生。
# 请谈谈CAS
CAS是什么?
比较并交换,是一条CPU并发原语。它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是保证了原子性的。 说人话就是:比较当前工作内存中的值和主内存中的值,如果相同则执行规定操作,否则继续比较,直到工作内存和主内存中的值一致为止。
CAS并发原语体现在java语言中就是Unsafe类中的各个方法。调用Unsafe类中的方法,JVM会帮我们实现出CAS汇编指令。这是一种完全依赖于硬件的功能,通过它实现了 原子操作。由于CAS是一种系统原语,原语属于操作系统用语范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,不允许中途断开, 也就是说,CAS是一条CPU的原子指令,不会造成所谓的数据不一致问题。
# CAS的底层原理 (CAS的底层思想就是自旋比较)
1.compareAndSwap的比较方法,是自旋的一个循环比较,直到成功为止。 2.Unsafe类 -------- 这个类是什么? 他在rt.jar包里面,是java从一开始就自带的一个运行jar包。是CAS的核心类,由于java是没有办法直接去访问底层系统的,Unsafe这个类就相当于是一个后门, 所以也是为什么他的方法都是native来修饰的。也正因为是这样,所以类中的方法都是直接可以调用操作系统底层资源来执行相应任务。
# CAS的好处:
保证了数据一致性的同时,也保证了并发性。
# CAS的缺点:
如果CAS比较失败,会一直尝试,不断地循环,长时间就会造成CPU的耗损会上升。 只能保证一个共享变量的原子操作,如果是多个共享变量,就无法保证了 同时,还有ABA问题 (这一点一定要答出来)
# 原子类AtomicInteger的ABA问题?原子更新引用知道吗?
# ABA问题是什么?
比如说一个线程T1从内存位置X中取出A,T2线程也从内存中取出A,然后T2线程进行了一些操作,将值改为B并写回, 然后又将值改为A再次写回,这时候线程T1进行CAS操作发现内存中仍然是A,然后T1线程操作成功。 尽管线程T1的CAS操作成功,但是不代表这个过程就是没有问题的。因为数据中途到底发生了什么变化,T1线程是不知道的。
ABA问题主要出现在引用地址的情况下,引用的如果是值,那并不会发生问题。当主内存中存的并不是实际的值,而是地址,那么就有可能导致,地址是不变,但是地址指向的那个 值已经发生改变。
# ABA解决办法:
使用AtomicStampedReference<> 版本号原子引用来解决,通过版本号来进行判断,每次修改都会使得版本号有改变,只要发生了改变,就可以知道数据中间被操作过。
# 什么情况下需要解决ABA?
看业务需求,如果只要是最终值符合就行,中间不重要,那么就可以忽略ABA问题。
PS:CPU底层的指令原语的原子性是在修改的时候保证不受其他线程抢断,所以在T1线程的操作时间里,T1并没有进行修改写回主内存的时候,其他线程是可以随意修改主内存的变量值。
如果不满足Atomic提供的原子包装类型,可以使用AtomicReference<> 来进行原子引用,泛型里写的就是你希望包装成原子的类。
# 线程的安全性如何获得保证:
工作内存与主内存同步延迟现象导致的可见性问题,可以使用synchronized和volatile关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。
对于指令重排导致的可见性问题和有序性问题,可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化。
# ArrayList为什么是线程不安全的?
因为ArrayList的add方法,写操作的时候,为了保证并发性和效率,没有加synchronized。
# 如何解决这个不安全问题?
1.使用Vector类,他和ArrayList的区别就是,他是保证线程安全的,因为使用了synchronized。但是这也会使得并发性下降。 2.使用Collections类,这个类是集合类的一个工具类。里面有对各种集合的一个加锁的方法。也是加了synchronized,但依旧还是ArrayList 3.在高并发包里,也就是concurrent里面的,CopyOnWriteArrayList(写时复制)
# CopyOnWriteArrayList是怎么做的?(说底层)
他的底层是这样来做的,他先拿到原数组以及原数组的长度,然后将原数组复制进新的数组里,同时再将 新的元素加进数组里。最后再将引用修改指向新的数组。 这样做的好处是可以对copyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以也体现的是一种读写分离的思想,读和写不同的容器。
多线程的时候可以保证安全的问题,但是当然这样也会损失效率问题,毕竟写的时候还是加锁的。
# HashSet线程安全吗?底层是什么。
线程不安全,HashSet的底层其实是HashMap。
# 如何解决不安全问题?
可以使用Collections类 或者使用CopyOnWriteArraySet,只不过他的底层其实是CopyOnWriteArrayList。
# 面试题:HashSet底层真的是HashMap吗?那如何解释add方法只用填一个值,而HashMap是键值对?
(一定要坚定的说底层的确是HashMap)我确定底层是HashMap,因为源码就是这样写的,至于add方法,在类的成员变量里是定义了一个静态的然后值是new了一个空的object类。 然后在add方法里呢,他调用的是map.put方法,add括号里填的值是作为key来存储,而value就是我刚才说的这个成员变量,也就是说value存进去的是一个空的object类。
# HashMap 如何解决不安全问题?
可以使用Collections类 或者使用concurrentHashMap。
# 公平锁与非公平锁:
并发包中ReentrantLock的创建可以指定构造函数的boolean类型来得到公平锁或非公平锁,默认是非公平锁
公平锁:是指多个线程按照申请锁的顺序来获取锁,类似排队打饭,先来后到。
非公平锁:是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。 在高并发的情况下,有可能会造成优先级反转或者饥饿现象。
# 两者区别:
并发包中ReentrantLock的创建可以指定构造函数的boolean类型来得到公平锁或非公平锁,默认是非公平锁
PS:ReentrantLock而言,非公平锁吞吐量比公平锁大, ReentrantLock对于synchronized来说,也是一种非公平锁
# 可重入锁(又叫递归锁)是什么?
并发包中ReentrantLock的创建可以指定构造函数的boolean类型来得到公平锁或非公平锁,默认是非公平锁
# 自旋锁是什么?
指的是尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。 好处是循环的比较直到成功获取锁为止,没有类似wait的阻塞
如何实现一个自旋锁? 思路:首先创建一个AtomicReference,泛型里写的是Thread,然后创建两个方法,一个是如果AtomicReference为null,那么就把当前线程丢进去,称之为上锁。 另一个方法则是判断AtomicReference不为null,那么就将他改为NULL,称之为释放锁。然后创建两个线程去访问,在第一个线程持有锁后,令其睡眠5秒, 二线程同时也会去访问方法,但是因为一线程持有了锁但并没有去释放,所以二线程就会一直在while循环,直到一线程醒来去释放锁,二线程才能获取锁并跳出循环。
独占锁:指该锁一次只能被一个线程所持有。对ReentrantLock对于synchronized而言都是独占锁。
共享锁:指该锁可被多个线程所持有。 对ReetrantReadWriteLock来说,其读锁是共享锁,他的写锁就是独占锁。 读锁的共享锁可以保证并发读是非常高效的,读写,写读,写写的过程是互斥的。
读写锁:可以允许多个线程对资源类的读取,但是只允许同一时间内只有一个线程去对资源类进行修改。并且可以允许读写的同时进行。
# CountDownLatch/CyclicBarrier/Semaphore使用过吗?
CountDownLatch:是一个同步工具类,它允许一个或多个线程一直等待,直到其他线程执行完后再执行。 可以说是一个计数器,他在new的时候就必须要传一个值,也就是需要等待的线程数量,每执行完一个线程就会减一,直到为0。 主线程必须在启动其他线程后立即调用CountDownLatch.await()方法。这样主线程的操作就会在这个方法上阻塞,直到其他线程完成各自的任务。
CyclicBarrier:与CountDownLatch相反,字面意思是可循环(Cyclic)使用的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞, 直到最后一个线程到达屏障时,屏障才会放开,所有被屏障拦截的线程才会继续干活,线程进入屏障通过CyclicBarrier的await()方法。 简单来说一句话:集齐七龙珠召唤神龙。
Semaphore:也是一个工具类,中文叫信号灯(信号量),从概念上来说,是多个线程操控多个资源类。主要用于两个目的,一个是多个共享资源的互斥使用,另一个用于并发线程数的控制。 理论上来说可以代替lock和synchronized. 多用于高并发场景下的秒杀,支付等。因为可以复用。 简单来说就是:抢车位,多个车子抢夺多个车位。
# 阻塞队列:
试图从空的阻塞队列中获取元素的线程将会被阻塞,直到其他的线程往空的队列插入新的元素 同样 试图往已满的阻塞队列中添加新元素的线程同样也会被阻塞,直到其他的线程从列中移除一个或者多个元素或者完全清空队列后使队列重新变得空闲起来并后续新增。
# 为什么用,有什么好处:
在多线程领域:所谓阻塞,在某些情况下会挂起线程(即阻塞),一旦条件满足,被挂起的线程又会自动被唤醒。
# 为什么需要BlockingQueue
好处是我们不需要关心什么时候需要阻塞线程,什么时候需要唤醒线程,因为这一切都交给BlockingQueue来完成。
队列(Queue)同属于Conllection的子接口,而阻塞队列(BlockingQueue)底下有七个实现类: ArrayBlockingQueue:由数组结构组成的有界阻塞队列。 * LinkedBlockingQueue:由链表结构组成的有界(但大小默认值为Integer.MAX_VALUE)阻塞队列。 // Integer.MAX_VALUE的大小是21亿4千多 * PriorityBlockingQueue:支持优先级排序的无界阻塞队列。 DelayQueue:使用优先级队列实现的延迟无界阻塞队列。 SynchronousQueue:不存储元素的阻塞队列,也即单个元素的队列。 * LinkedTransferQueue:由链表结构组成的无界阻塞队列。 LinkedBlockingDeque:由链表结构组成的无双阻塞队列。
PS:三个加了 * 的实现类很重要,线程池的底层用的就是这三个。
核心方法: 抛出异常:当阻塞队列满时,再往队列里add插入元素会抛出IIIegalStateException:Queue full 当阻塞队列空时,再往队列里remove移除元素会抛NoSuchException
特殊值:插入方法,成功true失败false ---- offer(e) 移除方法,成功返回出队列的元素,队列里如果没有元素就返回null ---- poll()
一直阻塞:当阻塞队列满时,生产者线程继续往队列里put元素,队列会一直阻塞生产线程直到成功成功添加或者程序响应中断退出。 当阻塞队列空时,消费者线程试图从队列里take元素,队列会一直阻塞消费者线程直到队列可用。
超时退出:当阻塞队列满时,队列会阻塞生产者线程一定时间,超时后生产者线程就会退出。 ---- offer(e,time,unit) / poll(time.unit) 例: offer("a",2L,TimeUnit.SECONDS)
SynchronousQueue没有容量: 与其他的BlockingQueue(阻塞队列)不同,SynchronousQueue是一个不存储任何元素的阻塞队列。 每一个put操作必须要等待一个take操作,否则不能继续添加元素,反之亦然。
# Lock对于synchronized 的区别:
1.原始构成: synchronized 是关键字,属于JVM层面 Lock是具体类(java.util.concurrent.locks.lock),是api层面的锁。
2.使用方法: synchronized 不需要用户去手动释放锁,当synchronized代码执行完成后,系统会自动让线程释放对锁的占用。 ReentrantLock 则需要用户去手动释放锁,如果没有,则有可能会发生死锁现象。
3.等待是否可中断: synchronized 不可中断,除非抛出异常或者正常运行完成。 ReentrantLock 可中断,1.设置超时方法 tryLock(Long timeout,TimeUnit unit)。 2.lockInterruptibly() 放到代码块中,调用interrupt()方法可中断。
4.加锁是否公平: synchronized 非公平锁 ReentrantLock 可以设置,默认是非公平锁,new时传入true,则为公平锁。
5.锁绑定多个条件Condition synchronized 没有 ReentrantLock 用来实现分组唤醒需要唤醒的线程们,可以精确唤醒,而不是像synchronized ,要么随机唤醒,要么全部唤醒。
# Condition 是什么?
Condition是在java 1.5中才出现的,它用来替代传统的Object的wait()、notify()实现线程间的协作,相比使用Object的wait()、notify(),使用Condition的await()、signal()这种方式实现线程间协作更加安全和高效。 因此通常来说比较推荐使用Condition,阻塞队列实际上是使用了Condition来模拟线程间协作。
Condition是个接口,基本的方法就是await()和signal()方法;
Condition依赖于Lock接口,生成一个Condition的基本代码是lock.newCondition() 调用Condition的await()和signal()方法,都必须在lock保护之内,就是说必须在lock.lock()和lock.unlock之间才可以使用
# Conditon中的await()对应Object的wait()
Condition中的signal()对应Object的notify();
Condition中的signalAll()对应Object的notifyAll()。
# 多线程有几种实现方式?
继承Thread类
实现Runnable接口,重写run方法。
实现Callable接口,将其传入FutureTask中。Thread类的构造方法只有传入Runnable接口,并没有传入Callable接口的方法,如果我们要使用Callable,那么我们就需要一个中间者, 他必须同时和Runnable接口和Callable接口都有联系。在查看Runnable接口API可以发现,有一个FutureTask的类是他的子类,同时构造方法则需要传入Callable接口。(适配器模式)
线程池
# 线程的生命周期:
线程的一个生命周期的话通常会经历五种状态. 1.新建
2.就绪
3.运行
4.阻塞
5.死亡
线程池: 线程池做的工作主要是控制运行的线程的数量,处理过程中将任务放入队列,然后线程创建后启动这些任务,如果线程数量超过了最大数量,那么超出数量的线程就会排队等候, 等其他线程执行完毕,再从队列中取出任务来执行。
# 线程池特点:
线程复用,控制最大并发数,管理线程
为什么要用线程池,优势是什么? 1. 降低资源消耗。通过重复利用已创建的线程,以此来降低线程创建和销毁所造成的消耗。 2.提高响应速度。当任务到达时,任务可以直接使用已创建的线程来立即执行,而无需等待线程创建。 3.提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。 2.
# 线程池的底层:
线程池的底层实际上就是ThreadPoolExecutor。
# 如何获取线程池?
常见常用的就三种方法 1.Executors.newFixedThreadPool(int) ---固定线程池--- 一池固定线程(取决于你传入的数字) ---- 执行长期的任务,性能比较好 2.Executors.newSingleThreadExecutor() ---单例线程池---- 一池一线程 ---------- 一个任务一个任务的执行的场景 3.Executors.newCachedThreadPool() ---缓存线程池--- 一池多线程 ------------- 适用于执行很多短期异步的小程序或者负载较轻的服务 另外两种 4.Executors.newScheduledThreadPool() 5.Executors.newWorkStealingPool() --- java8新出
# 线程池七大参数:
1.corePoolSize:线程池中的常驻核心线程 2.maximumPoolSize:线程池能够容纳同时执行的最大线程数,此值必须大于等于1. 3.keepAliveTime:多余的空闲线程的存活时间。 当前线程池,线程数量超过corePoolSize时, 当空闲时间达到keepAliveTime值时,多余空闲线程会被销毁直到剩下corePoolSize个线程为止。 4.unit:keepAliveTime的单位。简单来说就是设置空闲线程的存活时间,时,分,秒等具体单位。 5.workQueue:任务队列,被提交但尚未被执行的任务。 6.threadFactory:表示生成线程池中工作线程的线程工厂,用于创建线程一般用默认的即可。 7。handler:拒绝策略,表示当队列满了并且工作线程大于等于线程池的最大线程数(maximumPoolSize)
# 线程池底层工作原理:
1.在创建了线程池后,等待提交过来的任务请求。 2.当调用execute() 方法添加一个请求任务时,线程池会做出一些反应 2.1 如果正在运行的线程数量小于核心线程数,那么则会直接调用来运行这个任务。 2.2 如果正在运行的线程数量大于或者等于核心线程数,那么就会将这个任务放入队列中(阻塞队列) 2.3 如果这时候队列满了,且正在运行的线程数量还小于最大线程数,那么则是会创建非核心线程,并且立即运行这个任务。 2.4 如果队列满了,且正在运行的线程数量大于或者等于最大线程数,那么线程池会启动饱和拒绝策略。 3.当一个线程完成任务时,它会从队列中取下一个任务来执行。 4.当一个线程空闲超过一定时间(keepAliveTime),那么线程池就会开始判断,如果当前执行的线程数大于核心线程数,那么就会将非核心线程给停掉。所以线程池的所有任务完成后,它会收缩到只剩下核心线程在运行。
拒绝策略: 等待队列也已经排满了,再也塞不下新任务了,同时,线程池中的最大线程数也达到了,无法继续再接受新的任务了,这个时候我们就需要拒绝策略机制合理的处理这个问题。 、 JDK内置的拒绝策略种类: 1.AbortPolicy(默认):直接抛出RejectedExecutionException 异常,阻止系统正常运行。 2.CallerRunsPolicy:“调用者运行”的一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退给调用者,从而降低新的任务流量 3.DiscardOldestPolicy:抛弃队列中等待最久的任务,然后把当前任务加入队列中尝试再次提交当前任务。 4.DiscardPolicy:直接丢弃任务,不做任何处理,也不会抛出异常。如果允许任务丢失,这是最好的一种方案。
PS:以上内置的拒绝策略均实现了RejectedExecutionHandler 接口。
面试题:你工作中有没有用过线程池,单一,固定数,可变的三种创建线程池的方法,你用哪个多?(大坑) 答案: 一个都不用,我们是通过ThreadPoolExecutor的方式自定义的。因为单一和固定数线程池底层所使用的阻塞队列是LinkedBlockingQueue,它允许请求队列的长度是21亿,那在高并发环境下,一大堆请求一下子涌进来, 那就会导致OOM的问题发生。
# 线程池如何配置合理最大线程数:
CPU密集型: CPU密集的意思是该任务需要大量的运算,而没有阻塞,CPU一直全速运行。 CPU密集任务只有在真正多核的CPU上才可能得到加速(通过多线程) CPU密集型任务配置尽可能少的线程数量: 一般是CPU核数+1个线程的线程池
IO密集型: IO密集型即该任务需要大量的IO操作,即大量的阻塞。 在单线程上运行IO密集型的任务会导致浪费大量的CPU运算能力在等待上。 所以在IO密集型任务中使用多线程可以大大的加速程序运行,即使在单核CPU上,这种加速主要就是利用了被浪费掉的阻塞时间。
IO密集型时,大部分线程都阻塞,故需要多配置线程数: 一般是 CPU核数/1-阻塞系数 阻塞系数在0.8~0.9之间
# 死锁:
死锁是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉,那么程序就将会无法执行下去。如果系统资源充足, 进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁。
# 死锁产生主要原因:
1.系统资源不足 2.进程运行推进的顺序不合适 3.资源分配不当
# Callable:
多线程的第三种实现方式,方法带有返回值。
为什么已经有了Runnable接口,还需要Callable接口?
# 杂项:
synchronized的缺点: 并发性下降
java.util.ConcurrentModificationException 并发修改异常 (高并发常见异常)
https://www.bilibili.com/video/BV17E411a7nM?p=24 P24