java面试题之多线程

r囧r小猫 2023-06-07 12:11 31阅读 0赞

1谈谈 volatile 理解

volatile: 低配的同步锁,保障有序性(禁止指令重排,内存屏障),可见性(打小报告)
有序性是如何保证的:通过插入内存屏障,来禁止 屏障 之前与屏障之后的指令交换位置
可见性:

2谈谈CAS

含义,底层原理,Unsafe 类理解
CAS 缺点:

多线程环境下,对共享变量的操作,要么加锁,要么CAS

  1. 神马是CAS?
  2. CAS 底层实现?
  3. CAS 优缺点?
  4. CAS ABA 问题?
  5. 如何解决?
  6. 原子引用?

CAS 含义以及的层代码实现(AtomicInteger 类)

加锁 :保证只能同时有一个线程去操作 数据
CAS:比较交换,偏量值 ,主要思想是通过读取主内存的值 和预期旧值比较,如果相同,则将新值=预期旧值+偏量值 写入主内存(如果写入失败,重新读取旧值)
经典的使用例子便是jdk 中的atomic 包(查看源码可以看见有乐观锁的思想)
以AtomicInteger 的 getAndIncrement() 方法为例

  1. AtomicInteger atomicInteger=new AtomicInteger();
  2. atomicInteger.getAndIncrement();
  3. --》
  4. /** * Atomically increments by one the current value. * * @return the previous value */
  5. public final int getAndIncrement() {
  6. return unsafe.getAndAddInt(this, valueOffset, 1);
  7. }

Unsafe 类:

  1. /** * Atomically adds the given value to the current value of a field * or array element within the given object <code>o</code> * at the given <code>offset</code>. * * @param o object/array to update the field/element in * @param offset field/element offset * @param delta the value to add * @return the previous value * @since 1.8 */
  2. // O 是指当前对象,或者说是首地址
  3. //offset 是指内存偏移量
  4. // delta 是指变化量
  5. public final int getAndAddInt(Object o, long offset, int delta) {
  6. int v;
  7. do {
  8. // 读取当前对象+offset 内存地址上的值
  9. v = getIntVolatile(o, offset);// 获取主内存值
  10. } while (!compareAndSwapInt(o, offset, v, v + delta));// 如果没有交换成功,重新读取主内存值,进行下一次交换,直到成功为止
  11. return v;
  12. }

compareAndSwapInt 的底层就是 c语言的实现了,位于unsafe.cpp 中

  1. UNSAFE_ENTRY(jboolean,uNSAFE_cOMPAREaNDsWAPiNT(jnieNV *env,jobject unsafe,jlong offset,iint e,jint x))
  2. UnsafeWrapper("Unsafe_CompareAndSwapInt");
  3. oop p=JNIHandles::resolve(obj);// 对象首地址
  4. jint * addr=(jint*) index_oop_from_field_offset_long(p,offset);// 对象的属性字段的地址
  5. return (jint)(Atomic::cmpxchg(x,addr,e))==e;// 注意atomic 则保证此操作是原子性的不可中断的
  6. UNSAFE_END

CAS=Compare and Swap CPU 并发原语(原子性)

CAS 优缺点

  1. 如果CAS 失败,会一直尝试CAS,空转,CPU开销
  2. 只能保证单个变量
  3. ABA 问题

ABA 问题

主内存:初始值 1
线程 A 和线程B 读取主内存 值 为自己工作空间(拷贝一份)
线程 在CAS 过程中 将数据从主内存获取(得到1)
线程A 将数据改为 改为2 并且完成刷回主内存
线程A 将数据改为1 并且完成刷回主内存
线程B 执行 CompareAndSwap 操作 将数据 更改为3(CAS 个会成功)
因为线程B 读取到的仍是1(虽然不是最初的那个1,中途被改变,线程B无法感知)

忽略了中间过程的变化,只注意了首尾状态

原子引用更新

Atomic 只是提供了 基本数据类型的原子操作
如果现在有User/Account 这些类型也需要原子操作呢?

  1. class User{
  2. String name;
  3. int age;
  4. public User(String name, int age) {
  5. this.name = name;
  6. this.age = age;
  7. }
  8. @Override
  9. public String toString() {
  10. return "User{" +
  11. "name='" + name + '\'' +
  12. ", age=" + age +
  13. '}';
  14. }
  15. }
  16. public class AtomicReferenceDemo {
  17. public static void main(String[] args) {
  18. User user1=new User("zhangsan",10);
  19. User user2=new User("lisi",20);
  20. AtomicReference<User> atomicReference=new AtomicReference<>();
  21. atomicReference.set(user1);
  22. System.out.println(atomicReference.compareAndSet(user1,user2)+"\t"+atomicReference.get());
  23. System.out.println(atomicReference.compareAndSet(user1,user2)+"\t"+atomicReference.get());
  24. }
  25. }

输出结果:
在这里插入图片描述

3 原子类AtomicInteger ABA 问题?(待续)

如何解决ABA 问题
原子类的缺点:只能保证单个变量,而不能保证一个复杂的引用对象

4 原子更新引用(待续)

AtomicReference

解决ABA可以使用AtomicStampReference(相比AtomicReference 多了一个版本号stamp)

5 ArrayList 是线程不安全的,编写一个不安全的代码(待续)

给出解决方案(比较方案的底层实现)

  1. 同步容器 Vector
  2. Collections.synchronizedList (包装一层,需要先获取(mutux 互斥锁) 才能操作数组
  3. CopyOnWriteList 读写分离(写时复制,读 在 原数组读,写时,将拷贝原数组,然后在副本中写入,然后用副本替换原数组(使用了volatile 修饰数组,保证了可见性)

类似线程不安全的 还有hashMap,hashSet(底层是HashMap) 实现

6 公平锁、非公平锁、可重入锁、自旋锁(待续)

锁的功能:让临界区互斥执行
锁的内存语义:

  1. 线程释放锁时,JMM会将该线程的本地内存中的共享变量刷新到主内存中(和volatile 写有相同的内存语义,刷回主内存)
  2. 线程获取锁时,JMM会把该线程的本地内存中的的共享变量置为无效(和volatile 读有相同的内存语义,去主内存读取)。
  3. 公平vs 不公平
    公平:得到锁的顺序和申请的顺序一致(FIFO)
    非公平:不保证顺序,新线程申请锁是先尝试获取锁,获取失败,再排队(锁的实现默认是非公平的,性能更好)
  4. 可重入(递归锁)
    持有锁的线程,可以进入 该锁 控制的所有代码
    场景:单个线程重复多次访问同一个对象
  5. 自旋锁
    获取锁失败,会循环尝试获取锁,而不是阻塞,
    优点:不阻塞
    缺点:不释放cpu ,尝试减获取失败,会影响cpu 利用率

手写一个自旋锁(可以使用AtomicReference 去实现)

  1. public class SpinLock {
  2. AtomicReference<Thread> lock=new AtomicReference<>();
  3. public void lock(){
  4. Thread t=Thread.currentThread();
  5. System.out.println(t.getName()+" 进入 lock");
  6. while (!lock.compareAndSet(null,t)){
  7. // 自旋直到获取到锁
  8. }
  9. }
  10. public void unLock(){
  11. Thread t=Thread.currentThread();
  12. lock.compareAndSet(t,null);// 只有持有锁的线程才能解锁
  13. System.out.println(t.getName()+" unlock");
  14. }
  15. public static void main(String[] args) {
  16. SpinLock spinLock=new SpinLock();
  17. new Thread(()->{
  18. spinLock.lock();
  19. try {
  20. TimeUnit.SECONDS.sleep(3);
  21. }catch (InterruptedException e){
  22. e.printStackTrace();
  23. }finally {
  24. spinLock.unLock();
  25. }
  26. },"thread1").start();
  27. new Thread(()->{
  28. spinLock.lock();
  29. try {
  30. TimeUnit.SECONDS.sleep(1);
  31. }catch (InterruptedException e){
  32. e.printStackTrace();
  33. }finally {
  34. spinLock.unLock();
  35. }
  36. },"thread2").start();
  37. }
  38. }

效果

thread1 进入 lock
thread2 进入 lock
thread1 unlock
thread2 unlock

  1. synchronized vs lock
  2. 在这里插入图片描述

7 CountDownLatch/CycliBarrier/Semaphore(待续)

CountDownLatch: 倒计时,计数为0,回到主线程

8 阻塞队列知道吗(待续)

特点:

阻塞队列,首先它是一个队列,而一个阻塞队列在数据结构中所起的作用大致是:线程1往阻塞队列中添加元素,而线程2从阻塞队列中移除元素。
当阻塞队列是空时,从队列中获取元素的操作将被阻塞。 当阻塞队列是满时,往队列里添加元素的操作将被阻塞。

优点:

在多线程领域:所谓阻塞,在某些情况下会挂起线程(即阻塞),一旦满足条件,被挂起的线程又会自动被唤醒。

为什么需要BlockingQueue?

好处是我们不需要关心什么时候需要阻塞线程,什么时候需要唤醒线程,因为这一切BlockingQueue都给你包办了。

在concurrent包发布之前,在多线程环境下,我们每个程序员都必须去自己控制这些细节,尤其还要兼顾效率和线程安全,而这会给程序带来不小的复杂度。

核心方法:


































方法 无异常 带异常 阻塞操作 限时阻塞
添加 offer(不抛异常) add(满了会抛异常) put offer(E e, long timeout, TimeUnit unit)
移除 poll remove(空了会抛异常) take offer(E e, long timeout, TimeUnit unit)
第一个元素 peek element(空会抛异常)

种类,使用场景

9 线程池用过吗?ThreadPoolExecutor 的理解(待续)

线程池的优点:线程复用;并发量可控;资源可控
如何使用
重要参数:
核心线程数:内部员工,
阻塞队列:任务仓库;
非核心线程:外包人员,
无空闲线程,放仓库,仓库堆满了,任务办不完,让外包人员做,如果外包人员达到最大数量,也做不完,就交给拒绝策略
底层工作原理

10 线程池,如何设置合理参数(待续)

  1. 线程池的拒绝策略
  2. 你在工作中使用哪一个创建线程池的方法?哪一个用的多?
    答案是:一个都不用,生产中使用ThreadPoolExecutor 创建
  3. 既然jdk 提供了Executors 为什么不使用?
    因为自带的会默认是 请求队列最大创建Integer.MAX_VALUE ,可能会导致OOM,大量请求积压
  4. 在工作中如何使用线程池,是否自定义过线程池使用
    ThreadPoolExecutor 去创建

拒绝策略:(内置)均实现 RejectedExecutionHandler

ThreadPoolExecutor.AbortPolicy;// 抛异常
ThreadPoolExecutor.CallerRunsPolicy;// 调用者执行
ThreadPoolExecutor.DiscardOldestPolicy;// 移除最久未执行的任务
ThreadPoolExecutor.DiscardPolicy;// 静默处理,直接丢掉新任务

  1. 合理配置线程池你是如何考虑的
    cpu 密集型:(系统需要大量运算),尽量少的上下文切换,没有阻塞,cpu 一直全速运行
    一般公式:cpu核数+1个
    io 密集型:任务线程并不是一直执行任务,则应配置尽可能多的线程,如cpu 核数*2

IO密集型,即该任务需要大量的IO,即大量的阻塞。

在单线程上运行IO密集型的任务会导致浪费大量的CPU运算能力浪费在等待。

所以IO密集型任务中使用多线程可以大大的加速程序运行,即使在单核CPU上,这种加速主要就是利用了被浪费掉的阻塞时间。

IO密集型时,大部分线程都阻塞,故需要多配置线程数:

参考公式:CPU核数/1-阻塞系数 阻塞系数在0.8-0.9之间。

11 死锁编程及定位分析(待续)

什么是死锁
死锁代码
解决

12 java 里面锁谈谈你的理解,能说多少说多少

13 lock vs synchronized

lock:可中断,可非公平,可公平,多个condition(可以精确唤醒),手动释放
synchronized: 非公平,不可中断
例题:A线程打印10次,然后B打印10次,然后C 打印10次
重复11次上述流程

  1. **
  2. * A,B,C 三个线程,A 输出5次,B 输出10次,C输出15 重复11轮(A->B->C
  3. */
  4. public class ReadLock {
  5. private int state = 0;//a 0,b1,c2
  6. private ReentrantLock lock = new ReentrantLock();
  7. private Condition[] conditions=new AbstractQueuedSynchronizer.ConditionObject[3];// 每个线程对应一个condition
  8. {
  9. for (int i = 0; i < 3; i++) {
  10. conditions[i] = lock.newCondition();
  11. }
  12. }
  13. private void print(String content, int times, int threadNo) {
  14. try {
  15. lock.lock();
  16. while (state != threadNo) {
  17. conditions[threadNo].await();
  18. }
  19. for (int i = 0; i < times; i++) {
  20. System.out.println(Thread.currentThread().getName() + ":" + content);
  21. }
  22. } catch (Exception e) {
  23. } finally {
  24. state+=1;
  25. state%=3;
  26. conditions[state].signal();// 唤醒下一个线程
  27. lock.unlock();
  28. }
  29. }
  30. public static void main(String[] args) {
  31. ReadLock readLock=new ReadLock();
  32. new Thread(()->{
  33. for(int i=0;i<11;i++)
  34. readLock.print("a",5,0);
  35. },"A").start();
  36. new Thread(()->{
  37. for(int i=0;i<11;i++)
  38. readLock.print("b",10,1);
  39. },"B").start();
  40. new Thread(()->{
  41. for(int i=0;i<11;i++)
  42. readLock.print("c",15,2);
  43. },"C").start();
  44. }
  45. }

final 引用不能从构造函数溢出?

  1. class A{
  2. final obejct b;
  3. A(){
  4. b=bew Object()'}
  5. }

run

写final 域的重排序规则可以确保:将引用变量被任意线程可见之前,该引用变量指向对象的final域已经在构造函数正确初始化过 。
也就是如下操作:

  1. A a=new A();
  2. 过程细分为:
  3. 1. 进入A 构造函数
  4. 2. 初始化final 域(也就是b 属性)// 写final域的重排序规则保证这一步只能在构造函数内部完成,而不是重排序到构造函数外去完成
  5. 3. 退出A 构造函数
  6. 4. 堆上对象 被引用变量a 指向

happens before 原则

JSR-133:

  1. 程序顺序原则:一个线程中的每个操作,happens-before 与该线程的任意后续操作
  2. 监视器锁原则:对一个锁的解锁 happens-before 于随后对这个锁的加锁
  3. volatile 变量原则:对一个volatile 域的写 happens-before 于任意后续对该域的读
  4. start() 原则:线程A 中通过start() 方法启动线程B,则 A线程的ThreadB.start() happens-before 于线程B 中的任意操作
  5. join() 原则:线程A 中执行ThreadB.join(),线程B 的任意操作 happens-before 于线程A 从ThreadB.start() 返回。

发表评论

表情:
评论列表 (有 0 条评论,31人围观)

还没有评论,来说两句吧...

相关阅读