文章目录
一、进程
定义:系统分配资源的最小单位,一个任务就是一个进程
组成元素
(1)PID:进程的唯一身份标识
(2)进程的状态: 新建状态 —— 就绪状态 —— 运行状态 —— 阻塞状态 —— 销毁状态
(3)优先级:决定进程的执行顺序
(4)记账信息:为了进程的执行的相对公平
(5)上下文:保存本次的执行状态,以便下次仅需执行
(6)一组内存:指定进程需要使用的资源
并行和并发的区别
并行:多个任务在多个CPU下,同时执行角左并行(宏观与微观都是同时执行)
并发:多个任务采用时间轮询的方式进行执行就叫做并发。(宏观上是同时,但微观上是抢占式执行)
二、线程
1、线程和进程的关系
(1)进程——系统分配资源的最小单位;线程——系统调度的最小单位。
(2)一个进程至少包含一个线程,线程是依附于进程存在的,进程的实际执行单位是线程。
(3)进程之间不能共享资源,线程之间可以共享资源。
2、线程创建的三种方式
(1)继承 Thread 实现 run 方法class MyThread extends Thread
(2)实现 Runnable 重写 run 方法class MyRunnable implements Runnabl
(3)实现 Callable 重写 call 方法class MyCallable implements Callable<返回值类型>
—— 这种方法需要额外 new FutureTask<返回值类型>
对象来接受线程中输出的返回值
—— 上述三种方法都可以定义成为静态内部类之后再main函数中通过new这个内部类对象来调用线程的start方法实现
3、线程的构造方法
- 定义线程的名称:Thread(String name):创建线程并命名
- 定义线程的分组:ThreadGroup group = new ThreadGroup("组名");
Thread(group, 线程任务, ”线程名“);
- 定义线程的任务:
Runnable runnable = new Runnable() {
@Override
public void run() {
------在这里写具体执行的任务逻辑-------
}
};
4、线程的常用属性
- 线程的优先级(1-10,默认为5)Thread.setPriority(1-10之间的整数)
- 线程的名称Thread.setName("线程名")
- 线程的类型(守护线程[后台线程]Thread.setDaemon(true)
、用户线程)
5、线程的常用方法
- start()/run()的区别:
start():产生线程对象后,调用start()方法启动线程,线程处于运行状态(RUNNABLE)中就绪状态(Ready),此时线程等待被CPU调度,调度后在执行run()方法。使用start()启动线程,真正意义上实现了多线程
run():run()方法是Thread中的一个普通方法,当程序有两个新建线程时,调用run(),程序会按照顺序执行,并且执行的是主线程
- sleep() ——> Thread.sleep(),线程Thread会进入 TIME_WATTING状态
- join() ——> Thread.join() ,等待Thread线程执行完成
- 线程通讯的方法 :
wait():wait()
的使用要配合 synchronized使用;并且wait()
和 synchronized 必须保证要是一个对象
notifyAll():notifyAll()只会唤醒调用它的对象
LockSupport.park():让线程休眠,和wait()
一样会让线程进入WAITING状态;
LockSupport unpark(线程名):指定线程进行唤醒(优势)
- 线程的终止方法:
使用全局变量 —— 会让当前任务结束后在进行终止
使用interrupte()
终止线程 —— 无论线程是否在执行任务,都直接终止
· isInterrupted()
(静态方法),会重置interrupted结果(因为它是静态的,所有线程都会调用它,所以每次用完会重置)
· isInterrupted()
(实例方法),不会重置interrupted结果(这个方法只有当前线程实例在使用,所以可以不重置)
面试题1、wait()、notify()方法的使用时为什么需要加锁?
答:· wait()的含义是:释放当前对象的锁,并进入阻塞队列;
· · wait()方法进行了两步操作,首先是释放了当前对象的锁【要执行这步,首先当然得有锁才能释放锁】,其次是进入阻塞队列
· notify()的含义是:唤醒当前对象阻塞队列里的任一线程(保不准唤醒的是哪一个);
· · notify()方法用来唤醒一个线程,必须先找到要唤醒的对象,即获取该对象的锁,才去该对象对应的等待队列中去唤醒一个线程
· notifyAll()的含义是:唤醒当前对象阻塞队列里的所有线程
· 因调用wait()方法而导致阻塞的线程是放在阻塞队列
中的;
· 因竞争失败导致的阻塞的线程是放在同步队列
中的;
· notify()/notifyAll()的实质是把阻塞队列中的线程放到同步队列中;
面试题2、wait()和sleep()的区别:
答:· 相同点:
- 都会让线程进行休眠等待;
- 使用二者时都需要处理InterruptedException
异常(try/catch)
· 不同点:
- wait()
是Object中的普通成员方法,sleep
是Thread中的静态方法;
- wait()
使用可以不穿参数,sleep()
必须传入一个大于等于0的参数;
- wait(0)
会让当前线程无限休眠等待下去,sleep(0)
会立即触发一次CPU的抢占执行(相当于一瞬间的让位动作);
- wait()
使用必须配合加锁和释放锁,sleep()
使用不需要加锁和释放锁;
- wait()
会让线程进入WAITING状态(不传参时,没有明确的等待时间,当被唤醒或达到所传参数时间量后会唤醒进入就绪状态
);sleep()
会让线程进入TIME_WAITING状态(有明确等待时间,等休眠时间到了才会进入就绪状态)
面试题3、为什么wait()处于Object类中而不是Thread类中?
答:· wait()
的调用必须进行加锁和释放锁操作,而锁是属于对象级别的而非线程级别,也就是说锁针对于对象进行操作而不是线程;由于线程和锁是一对多的关系,一个线程可以拥有多把锁,而一把锁只能被一个线程所拥有;为了灵活操作,就将wait()放在Object类中;
面试题4、wait() 与 LockSupport 的区别?
答:· 相同点
- 都可以让线程进入休眠等待
- 都可以传参或不传参,且都是让线程进入WAITING状态
· 不同点:
- wait()
要配合锁一起使用,LockSupport
无需加锁
- wait()
只能唤醒对象的阻塞队列中的随即休眠线程和全部线程;LockSupport
可以唤醒指定休眠线程
6、线程的状态
线程状态 | 当前状态详细说明 |
---|---|
NEW | 线程刚创建,此时还未调用start()方法 |
RUNNABLE | 线程运行状态(包含Running[执行状态] 和Ready[就绪] ,在线程运行中,这两个状态之间会根据系统的调度即时间片的使用,其状态会发生改变) |
BLOCKED | 阻塞状态,线程不断尝试获取锁的过程 |
WAITING | 等待状态,没有明确的等待时间,该状态调用wait() / join() / LockSupport.park(线程对象) 方法进入 |
TIME_WAITING | 超时等待状态,有明确的等待时间,如使用有参的调用wait(long) / join(long) 等方法进入 |
TERMINATED | 线程结束状态 |
7、造成线程不安全的原因:
- CPU抢占式执行(根本原因)
- 非原子性:意思就是操作并不是一步操作就能执行完成的,当 cpu 执行一个线程过程时,调度器可能调走CPU,去执行另一个线程,此线程的操作可能还没有结束;(通过锁来解决)
- 多个线程同时修改同一个变量
- 指令重排序(编译器优化):为了提高运行效率,编译器做的优化
- 内存可见性:对于一个共享变量,当有一个线程对其进行操作,能及时地被其他线程看到(很关键的原因)
8、线程安全解决方案:
(1)使用final修饰变量:不可变的变量自然能保证可见性
(2)加锁(Synchronized
/ Lock
)
面试题5:Synchronized和Lock的区别:
· Synchronized是JVM提供的锁的解决方案;Lock是一个类,需要手动实现
· Synchronized和Lock修饰的代码范围不同,Lock只能修饰代码块
,而Synchronized适用于静态方法、普通方法和代码块
。
· Synchronized无需手动释放锁;Lock需要手动释放【Lock要在try之前设置;必须在finally中unlock释放锁】
· Synchronized是非公平锁;而Lock可以由用户设定是否为非公平锁
面试题6:Synchronized的实现原理、执行流程(锁优化):
· 实现原理:
- 在Java层面,是将锁信息存放在对象头中,当使用synchronized修饰代码时会在编译之后在代码的前面和后面加上监视器锁;
- 在操作系统层面,他是使用互斥锁来实现的。
· 执行流程(锁优化):
- 锁优化的流程分为四个阶段:
> 起初没有被访问时,是 无锁 状态
> 当第一次被访问时,升级为 偏向锁 状态【在对象头中记录当前对象的ID,记录到偏向锁ID里面,当下一次进程再进来时,会判断线程ID与偏向锁记录的线程ID是否是相同的,如果是相同则表示当前对象拥有锁,继续执行代码,否则通过自旋 从 偏向级锁 升级为 轻量级锁 】
> 当轻量级锁自选一段时间之后还没有得当前锁,则线程会进入等待队列,此时锁会升级为 **重量级锁 **
面试题7:造成死锁的原因(同时满足四个条件):
· 死锁:两个或两个以上的线程在执行的时候因为资源的争抢所造成的阻塞状态
- 互斥条件(当前对象被一个线程拥有后,不能被其他条件所拥有)
- 请求拥有条件(请求活得别人此时拥有的条件)
- 不可剥夺条件(资源被一个人拥有,若不主动释放,是不可被剥夺的)
- 环路等待条件
(3)使用volatile修饰变量
· 其作用主要有两个:
- 禁止指令进行重排序(编译器优化导致的指令重排序)
- 解决线程可见性问题
面试题8:volatile如何解决内存可见性问题?
· 未使用volatile,线程工作时会先在自己的工作内存中找变量,如果在自己的工作内存中没有找到,再去主内存中寻找变量;
· 当使用volatile,当每一次操作完变量,会强制删除掉线程工作内存中的此变量;
(4)ThreadLocal —— 线程级别的私有变量
· 既可以解决线程不安全的问题,又不排队执行
- set(T)
:将变量存放到线程中
- get()
:从线程中取得私有变量
- remove()
:从线程中移除私有变量
- initialValue()
:初始化方法
· ThreadLocal可以解决线程安全的问题;可以实现线程级别的数据传递;
· ThreadLocal在存储时只能存储一个值(一个对象);
面试题9:什么情况下会执行initialValue?为什么不会执行?:
答: ThreadLocal是懒加载,当调用了get方法之后,才会尝试执行 initialValue(初始化)
方法,此时会尝试获取ThreadLocal
当中set
进去的值,如果获取到了值,那么初始化方法就不会去执行;
面试题10:ThreadLocal的缺点有哪些?如何解决:
· 缺点1:不可继承 —— 子线程不能读取到父线程中的值
- 解决方案:使用InheritableThreadLocal
代替ThreadLocal
;但即便使用这种方法也 无法解决并列线程间 的数据共享(因为ThreadLocal本来就是线程级别的私有变量,如果两个线程之间的变量可以互通了,就没有意义了)
· 缺点2:脏读(脏数据)—— 在一个线程中读取到了不属于自己的数据
- 触发原因:
> 线程中使用ThreadLocal,不会触发脏读问题,因为每个线程都用的是自己的变量值和ThreadLocal;
> 线程池中使用ThreadLocal,会触发脏数据,由于线程池会复用线程,同时也会复用线程的静态属性;所以有可能就会导致初始化方法不会被执行,就会导致脏数据问题;
- 解决方案:
> 避免使用静态属性,因为静态属性在线程池中会被复用
> 如果一定要使用静态属性,那么每次在线程处理完请求之后(get()
结束)都要调用remove()
方法
· 缺点3:内存溢出问题(最易出现的问题)
- 内存溢出:指当一个线程执行完之后,不会释放这个线程所占用的内存,或者释放内存不及时的情况都叫做内存溢出。
- 触发原因:
> 从表层上讲,由于线程是在执行完任务后,线程资源就会释放掉;而线程池是长生命周期,可能会导致内存溢出;
> ThreadLocalMap中的强引用导致的内存溢出 —— 具体看 面试问题11;
- 解决方案:时刻在线程处理完请求后,调用remove()
方法;
面试题11:ThreadLocal的实现原理:
· 当一个线程 Thread
拥有了 ThreadLocal
后,线程会将ThreadLocal
放在ThreadLocalMap
里面,又因为ThreadLocalMap
中使用的时Entry<key,value>
进行存储的,当前线程所使用的这个ThreadLocal
对象就是作为key
,而通过set()
放进来的这个值就是value
面试题12:ThreadLocalMap发生内存泄漏的原因:
· 其实在ThreadLocalMap中的这个Entry数组中的key是继承于弱引用的(WeakReference),弱引用有一个特点,即只要触发过一次垃圾回收(GC),弱引用就会被回收掉,所以原本在程序运行中,当栈内存中ThreadLocal丢失掉 其 在堆内存中的引用对象时(ThreadLocal被置为null),栈内存中ThreadLocalMap 在堆内存中的引用对象Entry的key这个弱引用会被垃圾回收掉(因为它本来是指向ThreadLocal在堆内的对象的,但是由于它是弱引用,所以会被GC掉);但是Entry数组中的value同样在堆内存中有一块所指向的内存,这部分是强引用 ,无法被GC回收掉;而在实际应用中大多使用的是线程池,线程会被不断复用,这些强引用的value对象就会被一直堆积,造成内存泄漏;
· 为了解决内存泄漏的问题,要在每一次用完这个ThreadLocal
中的值后,即使用ThreadLocal.get()
后都要释放,即执行ThreadLocal.remove()
面试题13:HashMap 和 ThreadLocalMap 处理hash冲突的区别?
· HashMap使用的是链表法(竖着找)
· ThreadLocalMap使用的是开发寻址法(横着找)
· 为什么这样实现?
- 开放寻址法它的特点和使用场景是数据量比较少的情况下性能更好;而HashMap里面存储的数据通常情况下是比较多的,这个时候使用开放寻址法效率就比较低了,这个时候最好使用链式法
9、线程池:
(1)线程池的优点
· 线程数量和任务数量可控
· 线程池可以友好的拒绝任务
· 复用线程
· 线程池拥有更多的功能,比如执行定时任务
(2)线程池的7种创建方式 —— 线程池是 懒加载 的
· 创建固定线程个数的线程池
ExecutorService servie = Executors.newFixThreadPool(设置线程数量);
· 创建带缓存的线程池 —— 会根据任务数量,自动开辟线程数(适用有于大量短期任务)
ExecutorService servie = Executors.newCachedThreadPool();
· 创建可执行定时任务的线程池
ScheduledExecutorService service = Executors.newScheduledThreadPool(设置线程数量)
方法在运行定时任务时需调用service.scheduleWithFixedDelay()
,其中该方法有四个参数分别为:
①线程池的任务 ——> (new Runnable(){@Override run方法})
②定时任务延迟多长时间开始执行
③定时任务执行频率
④设置两个定时 时间参数的 时间单位
也可以调用service.scheduleAtFixedRate()
执行定时任务
scheduleWithFixedDelay()
任务开始时间是以 上次执行任务的结束时间 作为 开始时间的
service.scheduleAtFixedRate()
任务开始时间是以 上次任务的起始时间 作为 开始时间的
· 创建单个执行定时任务的线程池
ScheduledExecutorService service =Executors.newSingleThreadScheduledExecutor();
· 创建单个线程的线程池
ExecutorService service =Executors.newSingleThreadExecutor();
· 根据当前的工作环境(CPU核心数、任务量)—— 异步线程池
ExecutorService service =Executors.newWorkStealingPool();
· 使用 new ThreadPoolExecutor
方式创建线程池 —— 最重要和最常用的方式
- 创建时需要设置的七大参数:
> 核心线程数 — 快递站的正式员工数量
> 最大线程数量 — 快递站的正式员工和临时员工数量和
> 最大存活时间 — 临时员工的最大生命周期
> 最大存活时间的时间单位
> 任务队列 — new LinkedBlockingDeque<>(队列大小)
,必须设置任务队列大小,否则默认将为InterGER.MAX_VALUE
> 线程工厂 — static class MyThreadFactory implements ThreadFactory{....}
设置线程名称,优先级,守护线程
> 拒绝策略 — 五种拒绝策略
- 默认拒绝策略:什么都不设置,会自动调用ThreadPoolExecutor.AbortPolicy()
,抛弃最后一次的任务
- 调用主线程执行任务:new ThreadPoolExecutor.CallerRunsPolicy()
- 忽略新来的任务:new ThreadPoolExecutor.DiscardPolicy()
- 忽略最先加入到任务队列的任务:new ThreadPoolExecutor.DiscardOldestPolicy()
- 自定义拒绝策略:new RejectedExecutionHandler() {@Override rejectedExecution方法{....}}
(3)线程池的状态 —— 5个状态
· 线程在创建之后,若是不进行显示的 调用/关闭,则一直都是 RUNNING状态;
· 调用Shutdown()
和ShutdownNow()
都会结束RUNNING状态;
· 调用Shutdown()
:进入SHUTDOWN状态,此状态下会:
- 拒绝执行新任务
- 执行完任务队列中的所有任务
- 将线程中的所有任务全都关掉,进入TIDYING状态(销毁前置状态)
· 调用ShutdownNow()
:进入STOP状态,此状态下会:
- 拒绝执行新任务;
- 拒绝执行任务队列中的旧任务 —— 老子彻底不干了,啥都不干
- 直接将线程池中所有线程全部销毁,进入TIDYING状态
(4)线程池的执行流程
三、设计模式
(1)单例模式
- 饿汉方式
· 创建私有的构造函数 private Singleton(){ }
· 定义私有类对象,并且进行初始化 private static Singleton singleton = new Singleton();
· 提供公共的获取实例的方法 public static Singleton getInstance(){ return singleton; }
· 缺点:程序启动就会创建,浪费了系统资源
· 点击此处查看饿汉单例模式实现代码
- 懒汉方式
· 创建私有的构造函数 private Singleton(){ }
· 定义私有类对象,但不初始化 private static Single single = null;
· 提供统一的访问入口 —— 这里需要判断是否为第一次访问,并发情况下会造成线程不安全问题
- 使用双重效验锁 解决此处线程不安全问题
> 当两个线程同时访问 getInstance() 时,首先会进行判空,这是肯定两个线程获取到的都是为空
> 此时两个线程就排队执行锁里的内容,在这个过程中,哪个线程先获取到了锁,就会率先进行实例化对象
> 当实例化结束后,第二个线程进入锁后就会发现singletonLazy对象不为空已经实例化了,会直接返回singletonLazy对象
- 使用volatile 解决指令重排序问题
> 因为在 singletonLazy = new SingletonLazy();
这句代码并非是原子性的,理论上它的流程应该是:
> (1)先在内存中开辟空间 (2)初始化操作(3)引用变量指向内存区域
> 但是由于指令重排序问题,可能会变成(1)(3)(2)的执行顺序,这样会导致:
· 当线程1开辟内存空间,然后将变量指向了内存空间,此时线程1时间片用完
· 线程2执行程序时发现singletonLazy对象不为空,直接返回此时singletonLazy指向的内存空间
· 但此时,singletonLazy指向的内存空间还未初始化,所以会直接返回一个Null
> 为了解决这个问题 在 创建私有类对象时使用 volatile 解决指令重排序的问题
> 即 ——> private static SingletonLazy singletonLazy = null;
· 点击此处查看懒汉单例模式实现代码
(2)基于 生产者消费者 模型 实现 阻塞队列
· 生产者消费者模型:消费者消费掉生产者产生的数据
- 生产者:添加数据;当队列已满时,不再给队列添加数据,让线程阻塞等待;
- 消费者:取出数据;当队列为空时,阻塞等待;
· 点击此处查看阻塞队列具体实现代码
四、锁策略
(1)乐观锁 VS 悲观锁
- 乐观锁
· 认为一般情况下不会发生冲突,只有在数据更新时才会检测是否发生了冲突
- 若冲突 ——> 不进行修改
- 未冲突 ——> 则执行修改
· 乐观锁的具体实现:CAS(Compare And Swap)—— 比较并且交换
- CAS由三个部分组成:V(内存值)、A(旧值)、B(新值)
- 实现原理:当有线程1(旧值=10;新值=12)和线程2(旧值=10;新值=11),线程1先执行,想要把V的原始值修改成新值12,但由于线程1的时间片用完了,所以在线程1执行对比并替换前,线程2先执行,此时线程2要把内存中的值修改成新值11,此时 线程2首先会判断自己的线程中的旧值A是否等于V中的值(A==V ),如果时true,则说明没有并发冲突,此时刚好为true,所以现成2就会将内存中的V值,替换成线程2的新值11;这时线程1恢复执行,线程1会使用自己的A值(旧值)与此时内存中的V值进行比较(10 == 11 ?),所以结果为false;此时乐观锁第一次修改失败 ,这是线程1会通过自旋将自己的A值(旧值)修改成11,此时线程1种它的旧值A=11,要替换的新值B=12,当线程1自旋完成后,会继续进行CAS去修改内存V中的值,此时再去修改时,就会将V中的值修改为12
- 乐观锁的实现
· Atomic*家族
- AtomicInteger
、AtomicBoolean
、AtomicReference
等…
· CAS的底层实现原理:
- Java层面,CAS是实现了UnSafe,UnSafe类调用了C++的本地方法,通过调用操作系统的原子指令:Atomic::cmpxchg
实现CAS;
- 乐观锁的缺点以及解决方案 —— CAS造成的ABA问题
· 以银行转账为例:
- 当甲要给乙转账100元,此时甲连续点击了两次确认操作,假设此时就两个转账线程被触发;
- 在触发转账操作的瞬间对甲来说:
> 甲原本的 V(内存值)——账户余额为100;
> 甲转账前的 A(旧值) —— 100;
> 甲触发转账后的 B(预期新值) —— 0;
- 如果是单线程的运行下,两个线程依次进行上述操作当第二次线程进来发现V != A
,则会舍弃此次转账操作;
- 但如果在多线程下,当甲同时触发了两个线程,第一个线程完成了转账操作,恰好在此时,丙将100元转入给甲 ,这是另一个转账线程再来判断V == A
,就又会将这100元从甲的账户转给乙,甲就被转走了200元;这就是经典的的ABA问题。
- 点击此处查看ABA问题的具体实现代码
· ABA问题解决方案 —— 引入版本号
- 使用AtomicStampedReference
代替AtomicReference
来修饰要引入版本号的变量;
- 点击此处查看ABA问题解决方法的具体实现代码
- 悲观锁
· 认为在通常情况下一定会发生并发冲突,所以在进入方法之后就会加锁
· synchronized是一种悲观锁 / 可重入锁 / 非公平锁 / 非共享锁
(2)共享锁 VS 非共享锁
- 共享锁
· 一把锁可以被多个线程拥有
· 读写锁:
- 读锁 —— 用于读数据的锁,是共享锁
- 写锁 —— 用于写数据的锁,是非共享锁
- 读写锁的具体实现:ReentrantReaderWriterLock
,详细代码点击这里查看
- 读写锁的作用是将锁的粒度更加的细化;因为通常读锁不会造成线程不安全,写锁才会,所以细化锁的粒度有利于线程更有效的执行;
- 非共享锁
· 一把锁只能被被一个线程拥有
(3)公平锁 VS 非公平锁
· 锁的获取顺序必须和线程方法的先后熟悉怒保持一致,叫做公平锁
- 公平锁的优点:执行是有序的,结果是可以预期的
- new ReentrantLock(true)
· 锁得获取顺序和线程获取锁的前后顺序无关,叫公平锁(默认所策略)
- 非公平锁的优点:性能比较高的
- new ReentrantLock() / new ReentrantLock(false) / synchronized
(4)自旋锁
· 通过死循环while(true)
一直尝试获取锁
(5)可重入锁
· 当一个线程获取了一个锁之后,可以重复的进入
· 点击查看可重入锁的实现具体代码
面试题14:如何理解乐观锁和悲观锁? / 乐观锁和悲观锁是怎么实现的(CAS和Synchronized的实现原理)?
答: 乐观锁认为通常情况下不会出现并发冲突,所以只有在提交数据的时候才会检测是否有冲突;悲观锁认为事情总是发生在最坏的情况,所以每次在第一次进入之后都叫进行加锁操作;
· 乐观锁的实现机制为 CAS(比较并替换),CAS在JAVA中的具体实现是 Atomic家族。CAS 是由 V(内存中值)、 A(预期旧值、B(预期新值)三个组成,在执行的时候,使用V和A进行对比,如果结果为 true 这表明没有并发冲突,则可以直接修改,否则不能修改。 CAS 使用过调用C++ 提供的 UnSafe 中的本地方法(CompareAndSwapXXX)来实现。C++是通过调用操作系统的Atomic::cmpxchg(原子指令)来实现的;
· 悲观锁的实现以synchronized为代表, 在Java中是将锁的的 ID 存放到对象头来实现,synchronized 在 JVM 层面是通过监视锁来实现,synchronized 在操作系统层面是通过互斥锁 mutex 实现
面试题15:什么是读写锁?怎么理解的?
答: 读写锁是将锁的粒度分的更细了,分为读操作和写操作,并且读操作和写操作之间是互斥的,互斥的原因是为了防止出现脏数据(脏读)。读锁是可以多个线程共同拥有的,所以可以是共享锁;写锁是独占锁,在一个线程操作的时候其他线程不能操作。读写锁在java中使用ReentrantReadWriteLock
创建,通过readWriteLock.readLock()
和readWriteLock.writeLock()
分别得到读锁和写锁
面试题16:什么是自旋锁,自旋锁的缺点?
答: 自旋锁就是通过死循环一直尝试去获取锁。自旋锁的缺点是,如果发生 死锁,则会一直自旋(循环),会带来额外的开销。
解决方案: java中通过对对自旋增加明确的次数,当自旋次数大于规定次数还没有获取到锁,那就将它放到锁的等待队列中,等待有锁的时候再去唤醒它
面试题17:可重入锁的设计目的?
答: 若类里面的方法使用的都是一把锁,并且有循环调用的情况,此时需要使用可重入锁,否则就会出现死锁的情况。
面试题18:CAS机制带来的ABA问题是如何造成的?解决方案是什么?
答: 详见 乐观锁的缺点以及解决方案
,理解转账事例的流程与引入版本号作为解决方案。
面试题19:什么是偏向锁?
答: 偏向锁就是将自己的偏向锁的信息记录到对象头里面,偏向锁的ID就等于线程的ID;当有一个线程来获取锁时,将对象头中的偏向锁标识(即记录的线程ID)与 此时想要获取锁的线程的ID做对比;若相等,则说明该线程是当前锁的拥有者,可以执行代码;否则,无法执行。
五、JUC常用类(java.util.concurrent)
(1)可重入锁 —— ReentrantLock
· Lock 加锁操作 务必 写在 try 之前
- 因为 若没有进行加锁,仍会执行finally中的unlock释放锁操作;此时就会 报错
- 如果 lock加锁 写在 try 里面,则释放锁时的异常会覆盖掉 业务的 其他异常;
· finally 里面 务必 进行 unlock() 释放锁操作
- 不释放,锁可能一直被占用,可能会出现死锁;
(2)信号量 —— semphore
· 作用:实现限流,一次性发布一批锁
· acquire():尝试获取锁,如果可以正常获取到,则执行后面的业务逻辑,如果获取失败,则阻塞等待
· release():释放锁
· 以停车场,四辆车依次停放进两个车位为例,完整代码点击这里查看
(3)计数器 —— countDownLatch:
· 作用:当执行完一批操作之后,再去执行下一批操作
· await():等待,当线程数量不满足countDownLauth的数量的时候,执行此代码会阻塞等待,直到数量满足之后(所有线程都到达后)执行await()后的代码
· countDown():计数器
- 在CountDownLauth 里面有一个计数器, 每次调用countDown()方法的时候,计数器的数量-1, 直到减到0之后,就可以执行await()之后的代码;
· CountDownLatch 的缺点:
- CountDownLatch 计数器的使用时一次性的,当用完一次之后,就不能再使用了
· 以赛跑为例,等待所有选手都到达终点后,再宣布成绩,完整代码点击这里查看
(4)循环屏障 —— cyclicBarruer:
· 作用:与计数器功能相似,但他会重置计数器,反复使用
· cyclicBarrier.await();,在执行过程中有以下操作:
- 计数器 -1
- 判断计数器是否为0,如果为0执行之后的代
- 当计数器为0是,首先会执行 await()方法 之后的代码,将计数器重置码,如果不为0阻塞等待
· CyclicBarrier 和 CountDownLatch 区别?
- CountDownLatch 计数器只能使用一次, CyclicBarrier 的计数器可以重复使用
· 以10人赛跑为例,设定5为屏障,程序运行后,10个线程同时开始,但是只有5个线程在运行,当这个5个线程都执行完整后,突破屏障,开始执行另外5个线程;
六、线程安全容器(ConcurrentHashMap)
面试题20:对于HashMap、ConcurrentHashMap 和 Hashtable的理解【重中之重】:
· HashMap造成线程不安全的原因:
- 多线程情况下HashMap 在JDK1.7 下扩容(头插法)会造成死循环
> HashMap的存储结构为:数组 + 链表/红黑树
- 当链表长度大于8,数组长度大于64时,会升级为红黑树
- 当链表长度小于6,会降级为链表
> 双线程在进行扩容时可能会出现循环引用,循环引用就会导致死循环
- 多线程情况下HashMap 在JDK1.8 下扩容会造成数据覆盖
· ConcurrentHashMap如何实现线程安全
- jdk1.7时它使用了分段锁保证线程安全;把数组分成一些段,给每个段加锁 lock/unlock,这样保证了锁的粒度更细。假如有两个写入操作,二者可以并发操作,不用进行排队,使程序执行的性能更高,采用悲观锁机制。
- jdk1.8时进行锁优化,读的时候不加锁(读取的数据可能不是最新的,因为读取和写入可以同时进行),写的时候进行加锁,使用了大量的CAS、volatile,采用乐观锁机制。
· Hashtable如何实现线程安全
- HashTable是线程安全的容器,保证线程安全方式比较粗暴;会直接给put方法整体加锁(synchronized),导致程序性能很低,一般情况下不会使用HashTable.