目录
6、在启动线程的时候,可以使用run方法吗? run()和 start()有什么区别?
2、volatile 如何保证有序性?/ volatile 如何禁止指令重排?
3、StoreStore 屏障、StoreLoad 屏障、LoadLoad 屏障、LoadStore 屏障作用
1、JDK1.7的ConcurrentHashMap底层原理
2、JDK1.8的ConcurrentHashMap底层原理
二十、导致并发程序出现问题的根本原因是什么(Java程序中怎么保证多线程的执行安全)?
2、ArrayBlockingQueue和LinkedBlockingQueue区别
1、创建使用固定线程数的线程池newFixedThreadPool
2、单线程化的线程池newSingleThreadExecutor,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO)执行
4、提供了“延迟”和“周期执行”功能的ThreadPoolExecutor。
1、FixedThreadPool和 SingleThreadPool
一、线程和进程的区别?
1、进程
进程是指一个程序在执行过程中分配和管理资源的基本单位
当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
多实例进程就是可以打开多个
单实例进程就是只能打开一个
2、线程
线程是进程中的一个执行单元,是CPU调度和分派的基本单位
3、进程和线程对比
- 进程是指一个程序在执行过程中分配和管理资源的基本单位。而线程是进程中的一个执行单元,是CPU调度和分派的基本单位。
- 进程之间是相互独立的,线程共享进程的内存空间。
- 进程之间切换开销大,线程之间切换的开销小
二、并行和并发有什么区别?
1、单核CPU
- 单核CPU下线程实际还是串行执行的。
- 操作系统中有一个组件叫做任务调度器,将cpu的时间片(windows下时间片最小约为15 毫秒)分给不同的程房使用,只是由于cpu在线程间(时间片很短)的切换非常快,人类感觉是同时运行的。
- 总结为一句话就是: 微观串行,宏观并行。
- 一般会将这种线程轮流使用CPU的做法称为并发 (concurrent)。
对于单核CPU而言,一次只能执行一个线程,不过每个线程执行特别快,并且存在CPU调度,所以每个CPU执行一个线程,但是多个线程轮回切换。
2、多核CPU
每个核(core)都可以调度运行线程,这时候线程可以是并行的。
第一个时间片
第二个时间片
第三个时间片
第四个时间片
3、并发和并行的区别
- 如果是在单核CPU上,多个任务交替执行,那么就是并发。
- 如果是在多核CPU上同时执行多个任务,那么就是并行。
三、线程创建的方式
1、继承Thread类
- 继承Thread类
- 重写run方法
- 创建继承Thread类的实例
- 调用start()方法启动线程
2、实现Runnable接口
- 实现Runnable接口
- 重写run方法
- 创建实现Runnable接口的对象的实例
- 创建Thread类对象,并将实例包装在Thread类中
- 运行start方法启动线程
3、实现Callable接口
- 实现Callable接口
- 重写call方法
- 创建实现Callable的类的实例
- 创建Futuretask方法,将实例包装进去
- 创建Thread类,将Futuretask实例包装进去
- 执行start方法,开启线程
- 如果需要获得返回值,调用futuretask实例的get()方法获取返回值
4、线程池创建线程
- 实现Runnable接口
- 重写run()方法
- 创建线程池对象
- 提交任务
- 关闭线程池
5、Runnable 和 Callable 有什么区别?
- Runnable 接口run方法没有返回值。
- Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果。
- Callable接口的()方法允许抛出异常,而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛。
6、在启动线程的时候,可以使用run方法吗? run()和 start()有什么区别?
- 线程启动时候,只能使用start方法开启线程
- run方法执行只是使用当前主线程去执行这个方法
- start方法是创建一个线程,然后通过子线程去执行这个方法
- 同一个run方法可以执行多次,同一个start方法执行执行一次
四、线程包括哪些状态,状态之间是如何变化的?
1、线程的状态
- 新建
- 就绪
- 运行
- 阻塞
- 死亡
- 当创建一个线程但未执行start()方法,此时是新建状态
- 当执行start()方法就是就绪状态,等待CPU的调度
- 当获取CPU的调度的时候就是运行状态
- 当运行中线程无法获取锁、执行了wait()方法、执行sleep()方法,就进入阻塞状态
- 当阻塞中的线程获取锁,执行了notify()方法,等待时间过期,就进入就绪状态
- 当线程执行完毕,就进入死亡状态
2、概括
线程包括哪些状态
- 新建(NEW)
- 可运行(RUNNABLE)
- 阻塞(BLOCKED)
- 等待 (WAITING)
- 时间等待(TIMEDWALTING)
- 终止(TERMINATED)
线程状态之间是如何变化的
- 创建线程对象是新建状态
- 调用了start()方法转变为就绪状态
- 线程获取到了CPU的执行权,执行结束是终止状态在就绪状态的过程中,如果没有获取CPU的执行权,可能会切换其他状态
- 如果没有获取锁(synchronized或lock) 进入阻塞状态,获得锁再切换为可执行状态。
- 如果线程调用了wait()方法进入等待状态,其他线程调用notify()唤醒后可切换为可执行状态。
- 如果线程调用了sleep(50)方法,进入计时等待状态,到时间后可切换为可执行状态。
五、新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?
使用join()方法解决
代码例子:
- 首先创建线程t1
- 然后创建线程t2,在线程t2中加入t1.join()方法,就会阻塞当前线程让t1线程先执行完,然后再执行当前线程。
- 然后在线程t3加入t2.join()方法,就会阻塞当前线程让t2线程先执行完,再执行当前线程。
六、notify()和 notifyAlI()有什么区别?
- notifyAll:唤醒所有wait的线程
- notify: 只随机唤醒一个 wait 线程
七、java中wait和sleep方法的区别?
1、共同点
wait0,wait(long)和 sleep(long)的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态
2、不同点
方法归属不同
- sleep(long)是Thread 的静态方法。
- 而wait(),wait(long)都是Object 的成员方法,每个对象都有。
醒来时机不同
- 执行 sleep(long)和 wait(long)的线程都会在等待相应毫秒后醒来。
- wait(long)和 wait0 还可以被 notify 唤醒,wait0 如果不唤醒就一直等下去。
- 它们都可以被打断唤醒
锁特性不同(重点)
- wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制。
- wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)。
- 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁 (我放弃 cpu,你们也用不了)。
八、如何停止一个正在运行的线程?
- 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止。
- 使用stop方法强行终止(不推荐,方法已作废)。
- 使用interrupt方法中断线程:
- 打断阻塞的线程 ( sleep,wait,join )的线程,线程会抛出InterruptedException异常。
- 打断正常的线程,可以根据打断状态来标记是否退出线程。
九、Sychronized底层原理
1、Sychronized基本使用
如上图,对于抢票,如果不加锁,就会出现超卖或者同一张票多次出售的情况。为了避免这种情况就需要使用Sychronized。如下图结果:
Synchronized(对象锁)采用互斥的方式让同一时刻至多只有一个线程能持有(对象锁),其它线程再想获取这个(对象锁)时就会阻塞住。
2、synchronized关键字的底层原理-基础
- Monitor:翻译为监视器,由JVM提供,C++语言实现
- Owner:存储当前获取锁的线程的,只能有一个线程可以获取
- EntryList: 关联没有抢到锁的线程,处于Blocked状态的线程
- WaitSet:关联调用了wait方法的线程,处于Waiting状态的线程
当一个线程尝试获取锁的时候,首先让该线程的对象和Monitor进行关联,就会判断Monitor结构中的Owner是否为null,如果为null,则持有Owner并获取锁
当后续的线程如果获取锁的时候,就会判断Owner是否为null,如果不为null,则加入EntryList集合中阻塞并进行等待Owner为null。注意EntryList不是队列,里面的线程谁先抢到Owner谁获取锁。
对于WaitSet集合,则是线程调用wait()方法就会加入该集合中
3、 基础回答Sychronized底层原理概括
- synchronized关键字的底层原理
- Synchronized(对象锁)采用互斥的方式让同一时刻至多只有一个线程能持有(对象锁)
- 它的底层由monitor实现的,monitor是jvm级别的对象 (c++实现),线程获得锁需要使用对象(锁)关联monitor
- 在monitor内部有三个属性,分别是owner、 entrylist、 waitset:
- 其中owner是关联的获得锁的线程,并且只能关联一个线程
- entrylist关联的是处于阻塞状态的线程
- waitset关联的是处于Waiting状态的线程
十、synchronized关键字的底层原理-进阶
1、Monitor实现的锁属于重量级锁,你了解过锁升级吗?
Monitor实现的锁属于重量级锁,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低
在JDK 1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题
前面已经讲到过了重量级锁,那么lock对象是如何和Monitor进行关联的呢?下面进行讲解
2、对象的内存结构
3、MarkWord
- hashcode:25位的对象标识Hash码
- age:对象分代年龄占4位
- biased lock: 偏向锁标识,占1位,0表示没有开始偏向锁,1表示开启了偏向锁
- thread: 持有偏向锁的线程ID,占23位
- epoch: 偏向时间戳,占2位
- ptr_to lock _record: 轻量级锁状态下,指向栈中锁记录的指针,占30位
- ptr to heavyweight monitor: 重量级锁状态下,指向对象监视器Monitor的指针,占30位
因此,对象头Mark Word里面由指向重量级锁的Monitor的指针ptr to heavyweight monitor。这个指针会指向Monitor的地址。因此lock对象根据对象头的重量级锁指针保存到Monitor地址来进行关联的。
4、Monitor重量级锁
每个Java 对象都可以关联一个Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor对象的指针 。
当多个线程发生竞争就会升级为重量级锁。一旦发生竞争就会升级为重量级锁。
5、轻量级锁
在很多的情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁的概念。
锁重入情况下使用轻量级锁
如上代码,当前对象由两个方法,分别为method1和method2。其中method1方法加锁,代码块内执行了method2方法。这就会出现同一个线程对同一个锁获取了两次,即锁重入。这种情况下可以使用轻量级锁,否则对性能会产生较大的影响。
根据以上代码,可以得知对象的内存结构。第一部分为MarkWord,第二部分为klass Word,第三部分为object body。其中MarkWord保存的是无锁状态下信息。
如果此刻来了一个线程,要执行method1的方法,在线程执行的时候就会创建一个锁记录,叫做Lock Record。
Lock Record由两个结构,分别为:
- 锁记录:用来保存当前线程的轻量级锁的地址
- Object reference:用来指向获取该锁的对象。
每个线程的栈帧包含着轻量级锁的结构,里面的锁记录会存储锁对象的MarkWord
首先会让线程栈帧里面的Lock Record中的Object reference指向Object对象,是为了记录当前线程正在获取的锁对象,表示这个线程锁获取的锁是这个对象,因为Sychronized里面的对象是Object,所以线程锁获取的锁就是这个Object对象。
上面还有个Lock record地址 00。当前线程持有锁的时候,就会去修改Object对象的MarkWord。这个交换根据CAS算法来进行交换Lock record地址和Object对象的MarkWord进行交换,用CAS为了保证修改交换的过程是原子操作。
如果交换成功了,Object对象的MarkWord就会改称为Lock record的地址,表示这个对象现在拥有轻量级锁。
如果CAS失败了,第一个原因是多个线程竞争锁,这个时候不能使用轻量级锁,会直接升级为重量级锁。第二个原因是当前锁重入。
比如method1中调用了method2方法,在已经加锁的前提下再加一层锁,那么就会在栈帧中添加一个Lock Record,作为重入的计数,比如Lock Record是几个就会算是几重锁。
加入两次锁,至少要进行两次CAS操作。每加一个锁记录都要加入一个CAS操作。因为第一次已经将轻量级锁地址记录到Object对象的MarkWord中,因此第二次Lock Record就不用真正的去修改了。并且第二个LockRecord的Object reference也会指向Object。
当解锁的时候,就会从栈顶开始向下遍历,查看Lock Record中的MarkWord是否为null。如果为null,说明该锁是重入锁,那么从栈中删除,并且锁计数减1并重置。
当解锁最后一个锁的时候,LockRecord中的锁记录就会和Object对象的MarkWord进行交换。就会变回Object中MarkWord初始状态,表示当前对象未上锁。
6、轻量级锁流程的概括
加锁流程
- 在线程栈中创建一个Lock Record,将其obj字段指向锁对象
- 通过CAS指令将Lock Record的地址存储在对象头的mark word中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。
- 如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用。
- 如果CAS修改失败,说明发生了竞争,需要膨胀为重量级锁。
解锁流程
- 遍历线程栈找到所有obj字段等于当前锁对象的Lock Record。
- 如果Lock Record的Mark Word为null,代表这是一次重入,将obj设置为null后continue。
- 如果Lock Record的 Mark Word不为null,则利用CAS指令将对象头的mark word恢复成为无锁状态。如果失败则膨胀为重量级锁。
7、偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS操作
Java 6中引入了偏向锁来做进一步优化: 只有第一次使用CAS 将线程ID设置到对象的Mark Word 头中,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有。
以以上代码为例子如下:
对于Object对象MarkWord存储的数据,当线程1执行m1方法的时候,就会加锁。
因为当前线程是同一个线程,那么就会将当前线程Id写入Object中的MarkWord中,并且将偏向锁的标志改为1。
当执行m2方法的时候,就不会进行CAS操作,而是判断Object的MarkWord中的线程Id是否为当前线程Id,如果是那么就会将Lock Record加入栈帧中,并且设置锁记录为null,Object reference指向Object
8、Monitor实现的锁属于重量级锁,你了解过锁升级吗?
Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
一旦锁发生了竞争,都会升级为重量级锁
当一个线程访问同步块时,它首先尝试获取偏向锁,如果偏向锁被占用,那么它会尝试获取轻量级锁,如果轻量级锁获取失败,那么它会尝试获取重量级锁
十一、谈谈JMM(JAVA内存模型)
- JMM(Java Memory Model)Java内存模型,定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性
- JMM把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享区域(主内存)
- 线程跟线程之间是相互隔离,线程跟线程交互需要通过主内存
十二、谈谈你对CAS的理解
1、CAS概念
CAS的全称是:Compare And Swap(比较并交换),它体现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。
在JUC (java.util.concurrent ) 包下实现的很多类都用到了CAS操作
- AbstractQueuedSynchronizer (AQS框架)
- AtomicXXX类
2、 CAS操作流程
首先线程A和B都拿到了内存中的变量a的值,读取到自己的工作内存中,a的值都为100
当线程A想要对V修改的时候,首先从主内存V中拿到a的值。然后线程A首先将要修改的变量的当前值a=100和内存中a=100进行比较,线程A发现变量a的值等于内存中a的值,然后将预期值a=101赋值给内存中的a,即a=101。
同时线程B想要对V进行修改,线程B通过对比当前值a=100和主内存中的a=101不相等无法修改,然后就开始自选。
对于自选的过程就是重新获取主内存V的变量a的值,此刻获取的a=101,然后线程B将更新当前值和预期值分别为a=101,a=100。这时线程B去对比内存中的变量a的值,发现都为101,然后将预期值赋值给内存中的变量a,变量a=100。
3、CAS操作优缺点
- 优点:因为没有加锁,所以线程不会陷入阻塞,效率较高。
- 缺点:如果竞争激烈,重试频繁发生,效率会受影响 。
4、CAS典型ABA问题
ABA 是 CAS 操作的一个经典问题,假设有一个变量初始值为 A,修改为 B,然后又修改为 A,这个变量实际被修改过了,但是 CAS 操作可能无法感知到。
如果是整形还好,不会影响最终结果,但如果是对象的引用类型包含了多个变量,引用没有变实际上包含的变量已经被修改,这就会造成大问题。
如何解决?思路其实很简单,在变量前加版本号,每次变量更新了就把版本号加一,结果如下:
最终结果都是 A 但是版本号改变了。
从 JDK 1.5 开始提供了AtomicStampedReference
类,这个类的 compareAndSet
方法首先检查当前引用
是否等于预期引用
,并且当前标志
是否等于预期标志
,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
5、自旋开销问题
CAS 出现冲突后就会开始自旋
操作,如果资源竞争非常激烈,自旋长时间不能成功就会给 CPU 带来非常大的开销。
解决方案:
- 可以考虑限制自旋的次数,避免过度消耗 CPU;另外还可以考虑延迟执行。
6、只能保证单个变量的原子性
当对一个共享变量执行操作时,可以使用 CAS 来保证原子性,但是如果要对多个共享变量进行操作时,CAS 是无法保证原子性的,比如需要将 i 和 j 同时加 1:
i++;j++;
这个时候可以使用 synchronized 进行加锁,有没有其他办法呢?有,将多个变量操作合成一个变量操作。从 JDK1.5 开始提供了AtomicReference
类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
7、CAS底层实现
CAS底层依赖于一个Unsafe 类来直接调用操作系统底层的CAS 指令
十三、乐观锁和悲观锁
- CAS是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试。
- synchronized是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
十四、谈谈你对volatile
volatile 可以用来保证可见性和有序性。
1、volatile 如何保证可见性?
这就要提到 volatile 读写的内存语义(其实和 synchronized 加锁解锁的内存语义差不多)
①volatile 写的内存语义:当【写】一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
②volatile 读的内存语义:当【读】一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
2、volatile 如何保证有序性?/ volatile 如何禁止指令重排?
禁止指令重排的原理是插入【内存屏障】:编译器在生成字节码的时候,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序:
①在每个 volatile 写操作的前面插入一个【StoreStore 屏障】;在每个 volatile 写操作的后面插入一个【StoreLoad 屏障】
StoreStore
屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证 Store1 的写入操作对其它处理器可见。StoreLoad
屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
既然可以在 volatile 写后面添加 StoreLoad 屏障防止 volatile 写与后面的 volatile 读操作重排序,那为什么不选择在 volatile 读前面添加 StoreLoad 屏障呢?
事实上,这是 JMM 从整体执行效率的角度做出的最优选择:因为并发场景的常见使用模式是,一个写线程,多个读线程。当读线程的数量大大超过写线程时,选择在 volatile 写之后插入 StoreLoad 屏障显然能够获得更高的执行效率。
②在每个 volatile 读操作的后面插入一个 LoadLoad 屏障和一个 LoadStore 屏障
LoadLoad
屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。LoadStore
屏障:对于这样的语句 Load1; LoadStore; Store2,在 Store2 及后续写入操作被刷出前,保证 Load1 要读取的数据被读取完毕。
3、StoreStore 屏障、StoreLoad 屏障、LoadLoad 屏障、LoadStore 屏障作用
- StoreStore 屏障:作用在 Store 操作和 Store 操作之间的内存屏障。确保两个 Store 操作不会重排序,即使它们没有数据依赖关系。
- StoreLoad 屏障:作用在 Store 操作和 Load 操作之间的内存屏障。确保 Store 操作对其他处理器可见(刷新到内存)之后才能读取 Load 的数据到缓存。
- LoadLoad 屏障:作用在 Load 操作和 Load 操作之间的内存屏障。确保两个 Load 操作不会重排序,即使它们没有数据依赖关系。
- LoadStore 屏障:作用在 Load 操作和 Store 操作之间的内存屏障。确保所有先前的 Load 和 Store 操作对其他处理器可见(刷新到内存)之后才能执行新的 Store 操作。
十五、什么是AQS?
全称是 AbstractQueuedSynchronizer,即抽象队列同步器。它是构建锁或者其他同步组件的基础框架。
1、AQS与Synchronized的区别
2、AQS常见的实现类
- 阻塞式锁:ReentrantLock
- 信号量:Semaphore
- 倒计时锁:CountDownLatch
3、AQS基本工作机制
AQS内部有一个state变量,它被volitile所修饰,,可以被其他线程可以看到state的值 。
当有线程0想要获取这个锁,那么就会判断state是否为0,如果为0那么就将state的值改为1,表示该锁已被该线程所持有。
当线程1想要获取state的值的时候,发现state为1,那么就会进入等待队列。
当线程2也想获取state的值的时候,发现state为1,也进入等待队列
其中head为最早进入队列的线程,tail为最晚进入队列的线程。当线程0执行完成的时候,将state改为0,并且没有新来的线程,那么就会将head指向的线程持有锁,并将state改为1。
4、多个线程共同去抢这个资源是如何保证原子性的呢?
比如此刻,同时来了两个线程,那么这两个线程就会执行CAS操作,然后去抢这个锁。
此时线程0抢到了锁,那么就会将state改为1。线程4执行CAS操作发现无法修改,就进入等待队列。
因此在多个线程抢state的情况下,使用CAS操作保证原子性
5、AQS是公平锁还是非公平锁?
比如说此刻state为0,并且等待队列的队头元素线程1可以获取锁,但是同时也新来了线程5去抢这个锁,就会造成线程5和线程1去竞争这个锁,所以是非公平锁。
假设:如果线程5来了,线程1也具备获取锁的资格,但是线程5没去竞争而去等待队列等待,那么它是公平锁 。
因此AQS是非公平锁也可以是非公平锁。
6、概括
什么是AQS?
- 是多线程中的队列同步器。是一种锁机制,它是做为一个基础框架使用的像ReentrantLock、Semaphore都是基于AQS实现的。
- AQS内部维护了一个先进先出的双向队列,队列中存储的排队的线程在AQS内部还有一个属性state,这个state就相当于是一个资源,默认是0(无锁状态),如果队列中的有一个线程修改成功了state为1,则当前线程就相等于获取了资源。
- 在对state修改的时候使用的cas操作,保证多个线程修改的情况下原子性。
十六、ReentrantLock的实现原理
1、ReentrantLock的特点
ReentrantLock翻译过来是可重入锁,相对于synchronized它具备以下特点:
- 可中断
- 可以设置超时时间
- 可以设置公平锁
- 支持多个条件变量
- 与synchronized一样,都支持重入
2、底层原理
ReentrantLock主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似。
构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。
Sync是非公平锁和公平锁的父类
3、概括
- ReentrantLock表示支持重新进入的锁,调用lock 方法获取了锁之后,再次调用lock,是不会再阻塞。
- ReentrantLock主要利用CAS+AQS队列来实现。
- 支持公平锁和非公平锁,在提供的构造器的中无参默认是非公平锁,也可以传参设置为公平锁。
十七、Synchronized和Lock有什么区别?
1、相同点
都为悲观锁
都具备互斥,同步,锁重入
2、不同点
3、功能不同点
ReentrantLock拥有读写锁,锁的功能更多些
4、性能不同
无竞争时:synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
有竞争时:Lock 的实现通常会提供更好的性能
十八、死锁产生的条件是什么?
1、死锁产生条件
死锁:一个线程需要同时获取多把锁,这时就容易发生死锁
运行结果:
此时程序并没有结束,这种现象就是死锁现象...线程t1持有A的锁等待获取B锁,线程t2持有B的锁等待获取A的锁。
2、诊断死锁
用于对jvm的内存,线程,类的监控,是一个基于jmx的GUI性能监控工具打开方式:java 安装目录 bin目录下 直接启动jconsole.exe 就行。
点击选择运行的类
点击检查死锁
这样可以查看到死锁的情况
十九、ConcurrentHashMap详解
ConcurrentHashMap 是一种线程安全的高效Map集合底层数据结构:
- JDK1.7底层采用分段的数组+链表实现。
- JDK1.8采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二又树。
1、JDK1.7的ConcurrentHashMap底层原理
如上图,这是JDK1.7的底层结构图。JDK1.7时ConcurrentHashMap采用的是数组+链表的形式来保存数据的。1.7版本Segment数组不能扩容,在ConcurentHashMap创建好的时候,Segment数组就定死了,初始化为16,可以创建前修改。Segment数组元素保存的是每个HashEntry数组。对于HashEntry数组,这个可以扩容的并且可以存储数据的,每个HashEntry可以以链表的形式保存多个数据,如果是一个数据直接保存在HashEntry数组中。
如上图,这是添加数据的逻辑:首先根据key中的哈希值得到在setment数组中的位置。找到位置以后使用ReentrantLock将数组锁住。如果有多个线程同时获取当前锁,就会进行CAS操作自选,直到获取锁。因此多个线程只能有一个获取锁进行操作,所以性能会很差。
2、JDK1.8的ConcurrentHashMap底层原理
在JDK1.8中,放弃了Segment臃肿的设计,数据结构跟HashMap的数据结构是一样的: 数组+红黑树+链表。
采用CAS + Synchronized来保证并发安全进行实现
- CAS控制数组节点的添加。
- synchronized只锁定当前链表或红黑二叉树的首节点,只要hash不冲突,就不会产生并发的问题,相比于直接锁住整个数组,性能提高很多。
3、二者锁的分析
JDK1.7中的分段锁相较于JDK1.8中的锁性能还是较低的。因为JDK1.7中如果锁住Segment某个数组,那么这个Segment中的HashEntry就会全部被锁住。如果HashEntry有一个put()操作和多个get()操作,就会导致get()操作无法进行,受到性能影响。
对于JDK1.8细粒度锁而言,put()操作使用CAS操作对进行数据的添加,如果失败就说明有多个线程同时对该位置数据进行put()操作,那么在其他线程自旋的时候进行添加synchronized锁。因为只对链表或者红黑树的首节点添加锁,而非整个数组,所以性能会提高很多
二十、导致并发程序出现问题的根本原因是什么(Java程序中怎么保证多线程的执行安全)?
Java并发编程三大特性:
- 原子性
- 可见性
- 有序性
1、原子性
原子性:一个线程在CPU中操作不可暂停,也不可中断,要不执行完成,要不不执行
对于以上代码,就可能出现票数超卖或者一张票出售多次的情况,这是无法保证原子性的情况
保证原子性有两种方式
- 添加sychronized锁
- 添加JUC的Lock锁
如上,保证了原子性
2、可见性
内存可见性: 让一个线程对共享变量的修改对另一个线程可见
因为JIT的编译器优化,会造成可见性无法保证
解决这个问题:添加volitile关键字给共享变量
3、有序性
指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的
解决方法:使用volitile关键字
注:volitile只保证可见性和有序性,原子性无法保证
二十一、线程池的核心参数(线程池的核心原理)
1、线程池的核心参数
- corePoolSize:核心线程数目
- maximumPoolSize:最大线程数目=(核心线程+救急线程的最大数目)
- keepAliveTime:生存时间(救急线程的生存时间,生存时间内没有新任务,此线程资源会释敖)
- unit:时间单位- 救急线程的生存时间单位,如秒、毫秒等
- workQueue:没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
- threadFactory:线程工厂(可以定制线程对象的创建,例如设置线程名字、是否是守护线程等)
- handler 拒绝策略(当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略 )
2、线程池的执行原理
如上图,首先提交任务,判断核心线程是否满了,如果没满就让核心线程去执行任务,如果满了就判断阻塞队列是否已满。
如果阻塞队列没满,就将任务添加到阻塞队列中去,如果阻塞队列满了就判断当前线程数是否大于线程池的最大线程数。当核心线程和救急线程处理完任务以后就会处理阻塞队列中的任务。
如果当前线程数小于线程池的最大线程数,那么就会创建救急线程来处理任务。救急线程处理完以后如果阻塞队列还有线程,那么就用救急线程或者核心线程来处理阻塞队列中的任务。如果当前线程数大于线程池的最大线程数就会触发拒接策略处理。
拒接策略:
- AbortPolicy: 直接抛出异常,默认策略
- CallerRunsPolicy: 用调用者所在的线程来执行任务(用主线程执行当任务)
- DiscardOldestPolicy: 丢弃阻塞队列中靠最前的任务,并执行当前任务
- DiscardPolicy:直接丢弃任务
3、代码演示
public class Test implements Runnable{
//线程名称
public volatile String name;
//设置存活时间
public volatile long time;
public Test(String name,long time) {
this.name = name;
this.time=time;
}
public static void main(String args[]){
//核心线程数量
//最大线程池数量
//救急线程存活时间
//时间单位
//阻塞队列
//线程工厂,用来创建线程
//拒绝策略
ThreadPoolExecutor threadPoolExecutor = null;
try {
threadPoolExecutor = new ThreadPoolExecutor(
2,
4,
0,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(6),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()
);
for(int i=1;i<=10;i++){
threadPoolExecutor.execute(new Test("线程"+i,1000));
System.out.println(threadPoolExecutor.getQueue().toString());
}
} finally {
if (threadPoolExecutor != null) {
threadPoolExecutor.shutdown();
}
}
}
@Override
public void run() {
System.out.println(name+"正在执行中");
try {
Thread.sleep(time);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
如上代码,核心线程2个,最大线程数4个,队列大小为6,采用直接抛出异常策略。
运行结果
[]
线程1正在执行中
[]
[Test@677327b6]
线程2正在执行中
[Test@677327b6, Test@14ae5a5]
[Test@677327b6, Test@14ae5a5, Test@7f31245a]
[Test@677327b6, Test@14ae5a5, Test@7f31245a, Test@6d6f6e28]
[Test@677327b6, Test@14ae5a5, Test@7f31245a, Test@6d6f6e28, Test@135fbaa4]
[Test@677327b6, Test@14ae5a5, Test@7f31245a, Test@6d6f6e28, Test@135fbaa4, Test@45ee12a7]
[Test@677327b6, Test@14ae5a5, Test@7f31245a, Test@6d6f6e28, Test@135fbaa4, Test@45ee12a7]
[Test@677327b6, Test@14ae5a5, Test@7f31245a, Test@6d6f6e28, Test@135fbaa4, Test@45ee12a7]
线程9正在执行中
线程10正在执行中
线程3正在执行中
线程4正在执行中
线程6正在执行中
线程5正在执行中
线程8正在执行中
线程7正在执行中进程已结束,退出代码0
如上图可知执行过程,刚开始核心线程数量为2,但是有10个线程数量,然后给核心线程分配两个线程数,此时可以看到队列为空。当核心线程满了的时候,然后剩下的8个线程给队列添加线程,直到队列为满,此时然后查看当前线程数量和最大线程池数,发现<=10,然后创建救急线程来处理剩下的两个线程任务(10-2-6=8)。此刻核心线程和救急线程一旦有空就去处理剩余的线程。
public class Test implements Runnable{
//线程名称
public volatile String name;
//设置存活时间
public volatile long time;
public Test(String name,long time) {
this.name = name;
this.time=time;
}
public static void main(String args[]){
//核心线程数量
//最大线程池数量
//救急线程存活时间
//时间单位
//阻塞队列
//线程工厂,用来创建线程
//拒绝策略
ThreadPoolExecutor threadPoolExecutor = null;
try {
threadPoolExecutor = new ThreadPoolExecutor(
2,
4,
0,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(4),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()
);
for(int i=1;i<=10;i++){
threadPoolExecutor.execute(new Test("线程"+i,1000));
System.out.println(threadPoolExecutor.getQueue().toString());
}
} finally {
if (threadPoolExecutor != null) {
threadPoolExecutor.shutdown();
}
}
}
@Override
public void run() {
System.out.println(name+"正在执行中");
try {
Thread.sleep(time);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
如上代码,核心线程2个,最大线程数4个,队列大小为4,采用直接抛出异常策略。
执行结果
[]
[]
[Test@677327b6]
[Test@677327b6, Test@14ae5a5]
线程1正在执行中
[Test@677327b6, Test@14ae5a5, Test@7f31245a]
线程2正在执行中
[Test@677327b6, Test@14ae5a5, Test@7f31245a, Test@6d6f6e28]
[Test@677327b6, Test@14ae5a5, Test@7f31245a, Test@6d6f6e28]
[Test@677327b6, Test@14ae5a5, Test@7f31245a, Test@6d6f6e28]
线程7正在执行中
线程8正在执行中
Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task Test@330bedb4 rejected from java.util.concurrent.ThreadPoolExecutor@2503dbd3[Running, pool size = 4, active threads = 4, queued tasks = 4, completed tasks = 0]
at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2063)
at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:830)
at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1379)
at Test.main(Test.java:37)
线程3正在执行中
线程4正在执行中
线程6正在执行中
线程5正在执行中进程已结束,退出代码1
如上图可知,线程数为10,线程池最大线程数是8。刚开始核心线程为2,当把队列填满后比较最大线程数和线程数的时候发现超出线程池的最大线程数的时候,将最后两个线程抛出异常。
二十二、线程池中有哪些常见的阻塞队列
1、常见的阻塞队列
workQueue- 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
- ArrayBlockingQueue: 基于数组结构的有界阻塞队列,FIFO。
- LinkedBlockingQueue: 基于链表结构的有界阻塞队列,FIFO。
- DelavedWorkQueue:是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的。
- SynchronousQueue: 不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
2、ArrayBlockingQueue和LinkedBlockingQueue区别
3、有界无界代码演示
如图可以看到LinkedBlockingQueue可以设置有界参数
也可以设置无界参数
ArrayBlockQueue设置无界就会报错
只有有界才可以
4、两把锁一把锁区别
LinkedBlockQueue是链表头和尾分别有两把锁,而ArrayBlockQueue整体一把锁。
因此 LinkedBlockQueue效率高,ArrayBlockQueue效率低
二十三、如何确定核心线程数
1、lO密集型任务
一般来说:文件读写、DB读写、网络请求等
核心线程数大小设置为2N+1
2、CPU密集型任务
一般来说:计算型代码、Bitmap转换、Gson转换等
核心线程数大小设置为N+1
3、如何确定CPU核心数
4、如何确定核心线程数
①高并发、任务执行时间短 > (CPU核数+1),减少线程上下文的切换
②并发不高、任务执行时间长
- IO密集型的任务 >(CPU核数*2 + 1)
- 计算密集型任务 >( CPU核数+1)
③并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,设置参考 ②
5、lO密集型任务和CPU密集型任务选择线程数多少的原因
lO密集型任务:IO操作一般无法被中断,并且线程无法被切换,所以线程需要在允许的范围内尽可能多一点,这样就能最大效率的执行IO,所以采用2N+1。
CPU密集型任务:对于一些计算等任务对CPU的损耗较高,因此线程之间的上下文切换就会对性能产生较大的影响,这个时候为了减少线程上下文对性能的损耗就采用N+1。
二十四、线程池的种类有哪些?
在java.util.concurrent.Executors类中提供了大量创建连接池的静态方法,常见就有四种
1、创建使用固定线程数的线程池newFixedThreadPool
- 核心线程数与最大线程数一样,没有救急线程
- 阻塞队列是LinkedBlockingQueue,最大容量为IntegerMAX_VALUE
适用于任务量已知,相对耗时的任务
2、单线程化的线程池newSingleThreadExecutor,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO)执行
- 核心线程数和最大线程数都是1
- 阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE
3、可缓存线程池newCachedThreadPool
- 核心线程数为0
- 最大线程数是IntegerMAX_VALUE
- 阻塞队列为SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作
4、提供了“延迟”和“周期执行”功能的ThreadPoolExecutor。
5、概括
线程池的种类有哪些
- newFixedThreadPool: 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
- newSingleThreadExecutor: 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO)执行。
- newCachedThreadPool: 创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
- newScheduledThreadPool: 可以执行延迟任务的线程池,支持定时及周期性任务执行。
二十五、为什么不建议用Executors创建线程池
线程池不允许使用 Executors 去创建,而是通过ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
Executors 返回的线程池对象的弊端如下:
1、FixedThreadPool和 SingleThreadPool
允许的请求队列长度为Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM
2、 CachedThreadPool
允许的创建线程数量为 Integer.MAX VALUE,可能会创建大量的线程,从而导致OOM
二十六、线程池的使用场景