Java八股文总结(一)

Java八股文总结(二):https://blog.csdn.net/weixin_44780078/article/details/131796843

文章目录

一、JUC相关

1. 什么是线程池,线程池的作用?

线程是一种系统资源,每创建一个线程都需要占用一定的内存(需分配栈内存),如果在高并发的情况下,一瞬间来了很多请求,每个请求都需要创建一个或多个线程,这样务必会占用太多的资源,也可能会导致out of memory(内存溢出)的情况发生;为了避免这种情况,于是就引入了线程池,线程池和数据库连接池非常类似,可以把线程池看作是一个管理线程的容器,可以统一管理和维护线程,减少没有必要的开销。


2. 为什么需要使用线程池?

上面也提到了什么是线程池,由于我们的计算机 cpu 数量有限,创建太多的线程会导致大部分线程因为得不到 cpu 的调度而导致阻塞,cpu 进行过多的线程上下文切换(新建—就绪—运行—阻塞—死亡)也会严重影响性能。而线程池是提前创建一批线程,让这些线程一直处于运行状态,并且可以得到重复的利用,这样就避免了过多的线程去新建或者上下文切换所造成的耗时。


3. 在哪些地方会使用到线程池?

一般在实际的开发中,是禁止自己去 new 线程的,假如在一个线程中使用到了 new 线程,一旦被有意的人发现这个 bug 后,发起恶意的攻击,就会创建太多的线程导致服务器 cpu 飙高宕机。因此可以说在实际的开发环境中必须使用线程池维护和创建线程。


4. 线程池有哪些作用?

  • 降低资源消耗:线程池的核心就是复用机制,通过提前创建好一批线程,并且处于运行状态,实现复用,从而降低线程创建和销毁造成的损耗。
  • 提高响应速度:任务到达时,无需等待线程创建,而是立即执行。
  • 提高线程的可管理性:如果线程被无限创建,不仅会消耗资源,还会因为线程的不合理分布导致资源调度失衡,使用线程池可以做到统一的分配和维护。
  • 提供一些额外的功能:线程池具备可扩展性,允许我们向其中增加更多的功能,比如延迟定时线程池 ScheduledThreadPoolExecutor,允许任务延期执行或定期执行。

5. 线程池的创建方式

Executors 类中提供了四种创建线程池的方式,它们是 jdk 中已经封装好的,底层都是基于ThreadPoolExecutor 构造函数通过传递不同的参数来创建线程池,因为 ThreadPoolExecutor 底层是采用无界队列封装的,可能会造成线程溢出问题。因此非常遗憾阿里巴巴开发手册里面都不推荐这四种方式创建线程池。

  • 可缓存线程池:Executors.newCachedThreadPool()。发现默认线程数是 0,最大线程数是无限个 Integer.MAX_VALUE。
// Executors.newCachedThreadPool(); 可缓存线程池
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>(),
                                  threadFactory);
}

  • 可定长度线程池:Executors.newFixedThreadPool( int n )。发现默认线程数是传入的 n,最大线程数也是传入的 n。
// Executors.newFixedThreadPool( int n ); 可定长度线程池
public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}

  • 可定时线程池:Executors.newScheduledThreadPool( int corePoolSize )。默认线程数是传入 的 corePoolSize。最大线程数是无限个 Integer.MAX_VALUE。
// Executors.newScheduledThreadPool( int corePoolSize ); // 可定时线程池
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
    return new ScheduledThreadPoolExecutor(corePoolSize);
}

public ScheduledThreadPoolExecutor(int corePoolSize) {
    super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
          new DelayedWorkQueue());
}

  • 单例线程池:Executors.newSingleThreadExecutor() 。默认线程数是1,最大线程数也是1。
// Executors.newSingleThreadExecutor(); // 单例线程池
public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService
        (new ThreadPoolExecutor(1, 1,
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>()));
}

  • java 8 中新加入了一个新的线程池:Executors.newWorkStealingPool()。
 public static ExecutorService newWorkStealingPool() {
     return new ForkJoinPool
         (Runtime.getRuntime().availableProcessors(),
          ForkJoinPool.defaultForkJoinWorkerThreadFactory,
          null, true);
 }

这个线程池和上述的四种线程池有区别,它的底层不再是 ThreadPoolExecutor,而是通过 ForkJoinPool 去创建线程。相比之下,这个线程池的优点就是,没有了上述四种方式的无界队列,所以也就不会有内存溢出的情况发生。

6. 线程池底层是如何实现复用的?

线程池底层实现原理:
在这里插入图片描述
线程池核心点:复用机制。

  1. 提前创建好固定的线程,并且一直处于运行状态。这种一直运行的状态是通过死循环实现的,比如 new Thread(), 在线程后加一个 while(){},线程就会一直处于运行状态。
  2. 将线程任务存放到并发队列中,交给正在运行的线程执行。
  3. 正在运行的线程从队列中获取该任务执行。

7. java手写模拟线程池

先补充一个知识点:并发队列(LinkedBlockingDeque)

    public static void main(String[] args) {
        /**
         * 这个LinkedBlockingDeque是一个无界队列
         * 无界与有界的区别:
         * 无界:对容量没有限制
         * 有界:对容量有限制
         */
        LinkedBlockingDeque<String> strings = new LinkedBlockingDeque<>();
        strings.add("张三");
        strings.add("李四");
        strings.add("王五");
        System.out.println(strings.poll());
        System.out.println(strings.poll());
        System.out.println(strings.poll());
        System.out.println(strings.poll());
    }

poll() : 从队列中移除一个元素,先插入的元素会先移除。当没有元素时调用 poll() 方法为 null。


并发知识点演示结束,开始手写线程池:

MyExecutors.java

public class MyExecutors {

    private List<workThread> workThreadList;       // 实现创建好的一批线程
    private BlockingDeque<Runnable> runnableDeque; // 并发队列
    private boolean isRun = true;                  // 运行状态

    /**
     * @param maxThreadCount 最大线程数
     * @param queueSize      队列容量
     */
    public MyExecutors(int maxThreadCount, int queueSize) {
        // 1.限制队列容量
        runnableDeque = new LinkedBlockingDeque<>(queueSize);

        // 2.提前创建好固定的线程,一直处于运行状态
        workThreadList = new ArrayList<>(maxThreadCount);
        for (int i = 0; i < maxThreadCount; i++) {
            new workThread().start();
        }
    }

	// 工作线程一直处于运行状态
    class workThread extends Thread {
        @Override
        public void run() {
            while (isRun || runnableDeque.size() > 0) {
                Runnable runnable = runnableDeque.poll(); // 并发队列中取出一个线程,执行
                if (runnable != null) {
                    runnable.run();
                }
            }
        }
    }

    public boolean execute(Runnable runnable) {
        // 向队列中添加线程,当队列中满了后,就会添加失败
        return runnableDeque.offer(runnable);
    }


    public static void main(String[] args) {
        MyExecutors myExecutors = new MyExecutors(3, 6);
        for (int i=0; i<10; i++) {
            final int finalI = i;
            myExecutors.execute(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName() + "," + finalI);
                }
            });
        }
        myExecutors.isRun = false;
    }

}

分析上述代码,在MyExecutors() 初始化时,就创建了 3 个复用的线程,并且定义了一个容量为 6 的并发队列。

1、提交的线程任务数 ≤ 核心线程数,核心线程直接复用。
2、核心线程 < 提交的线程任务数 ≤ 最大线程数,如果队列容量未满,将线程任务缓存到队列中。
3、核心线程 < 提交的线程任务数 ≤ 最大线程数,如果队列容量已满,最多再创建(最大-核心)个线程,多余的线程拒绝。


8. ThreadPoolExecutor核心参数有哪些?

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)
  • corePoolSize:核心线程数量,一直保持运行的线程。
  • maximumPoolSize:最大线程数,线程池允许创建的最大线程数。
  • keepAliveTime:超出 corePoolSize后创建的线程的存活时间。
  • unit:keepAliveTime 的时间单位。
  • workQueue:任务队列,用于保存待执行的任务。
  • threadFactory:线程池内部创建线程所用的工厂。
  • handler:任务无法执行时的处理器,处理被拒绝的任务。

9. 线程池创建的线程会一直处于运行状态吗?

答:分情况,如果是核心线程数,则会一直保持运行状态,如果是最大线程数创建的非核心线程,则不会,并且有一个默认存活时间,超过存活时间就会销毁。

例如配置核心线程数 corePoolSize 为 2,最大线程数 maximumPoolSize 为 5,我们可以通过配置超出corePoolSize 核心线程数后创建的线程的存活时间,假如设为60秒,在60秒内非核心线程没有任务执行,则会进行销毁。

10. 线程池队列满了,任务会丢失吗?

如果队列满了,且任务总数大于最大线程数则当前线程走拒绝策略。

线程池有如下拒绝策略:

  • AbortPolicy:丢弃任务,抛运行时异常。
  • CallerRunsPolicy:执行任务。
  • DiscardPolicy:忽视,什么都不会发生。
  • DiscardOldestPolicy:从队列中踢出最先进入队列(最后一个执行)的任务。
  • 实现 RejectedExecutionHandler 接口,可自定义处理器。

11. 为什么阿里巴巴不建议使用 Executors ?

因为 Executors 底层是采用 ThreadPoolExecutor 构造函数来创建线程池,ThreadPoolExecutor 中有一个参数名为 LinkedBlockingQueue 的无界队列用来存放线程任务,这个无界队列的上限是 Integer.MAX_VALUE,由于上限太大,如果不断的存放线程任务就会不断的占用内存,最终可能会导致内存溢出。

在这里插入图片描述

二、JUC—锁

1. 什么是悲观锁,什么是乐观锁?*

  • 悲观锁:悲观锁认为线程安全问题一定会发生。

    • 悲观锁特点:当多个线程对同一个变量进行修改时,只有一个线程能修改成功,并且其他线程处于阻塞状态。大家依次获取锁执行,效率比较低。
    • 站在mysql的角度分析:当多个线程对同一行数据实现修改的时候,只有一个线程能修改成功,谁能获取到了行锁,谁就能对这行数据进行修改,其他线程处于阻塞状态,
    • 站在java角度分析:与上面的描述一样,创建的线程如果没有获取到锁,就会进入阻塞状态,并且后期想要唤醒锁,就需要 cpu 进行重新调度,重新从就绪状态调度为运行状态,效率也非常低。
      在这里插入图片描述
  • 乐观锁:乐观锁认为线程安全问题不一定会发生。

    • 站在mysql的角度分析:乐观锁有版本号法,在表中新增一个 version (版本)字段,update时把 version 当作判断条件,每 update 一次行记录,version就+1,这样就能避免线程安全问题发生。如果update失败,线程则不断重试,因此成功率也会降低,这是缺点。
    • 站在java的角度:CAS法,CAS是一种无锁算法,它包含三个操作数—内存位置的值(V)、预期原始值(A)和修改后的新值(B)。
      (1)如果内存中的值和预期原始值相等, 就执行操作,并将修改后的新值保存到内存中。
      (2)如果内存中的值和预期原始值不相等,说明共享数据已经被修改,放弃已经所做的操作,然后重新执行刚才的操作,直到重试成功。

通俗的说就是,CAS算法,有一个预设值、修改数据的时候,会传入一个标记值和一个更新值,如果预设值和标记值相同,则把预设值改为更新值,否则不做任何操作。

2. 公平锁与非公平锁?

  • 公平锁:根据线程请求锁的顺序排列,先请求的就先获取锁,后请求的就后获取锁,采用队列存放,类似于排队。ReentrantLock(true) 是公平锁。
  • 非公平锁:不是根据请求的顺序排列,而是通过争抢的方式获取锁,靠cpu调度,调度到哪个线程,哪个线程就先执行。ReentrantLock(false)、synchronized 是非公平锁。

3. 对 CAS 锁的理解?

CAS:Compare and Swap的简称,翻译成比较并交换。通俗的说就是,CAS算法,有一个预设值、修改数据的时候,会传入一个标记值和一个更新值,如果预设值和标记值相同,则把预设值改为更新值,否则不做任何操作。


4. 模拟手写 CAS 锁。

public class AtomicTryLock {

    private AtomicInteger cas = new AtomicInteger(0);
    private Thread lockCurrentThread; // 记录锁被哪个线程所持有

    /**
     * 获取锁
     * @return
     */
    public boolean tryLock() {
        boolean result = cas.compareAndSet(0, 1);
        if (result) {
            lockCurrentThread = Thread.currentThread();
        }
        return result;
    }

    /**
     * 释放锁
     * @return
     */
    public boolean unLock() {
        if (lockCurrentThread != Thread.currentThread()) {
            return false;
        }
        return cas.compareAndSet(1, 0);
    }

    public static void main(String[] args) {
        AtomicTryLock atomicTryLock = new AtomicTryLock();
        IntStream.range(1, 10).forEach((i) -> new Thread(() -> {
            try {
                boolean result = atomicTryLock.tryLock();
                if (result) {
                    atomicTryLock.lockCurrentThread = Thread.currentThread();
                    System.out.println(Thread.currentThread().getName() + ",获取锁成功~");
                } else {
                    System.out.println(Thread.currentThread().getName() + ",获取锁失败~");
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                if (atomicTryLock != null) {
                    atomicTryLock.unLock();
                }
            }
        }).start());
    }
}

5. CAS 锁的优缺点。

  • 优点:没有获取到锁的线程,不会发生阻塞,会一直通过循环去重试。
  • 缺点:通过死循环不断重试,会导致 cpu 资源的消耗比较高,需要控制循环次数,避免 cpu 飙高的问题发生。

6. CAS 如何解决 ABA 的问题。

  • CAS 判断原理:把内存中的预设值和传入的标记值做判断,看是否相等,如果相等,就把内存值替换成更新值。

  • ABA 问题:假如预设值是A,第一次修改为B,再接着修改为A,这样绕了一圈还是修改成了原先的值,假如我们再把 A 修改成 C 还是能修改成功。这就导致原本值已经发生了变化,但是修改判断时好像又没有变化,这就出现了 ABA 问题。

解决:此处引入一个新的方法:AtomicStampedReference。

AtomicStampedReference:只要有其它线程操作过共享变量,那么自己的 cas 就算失败,这时,仅比较值是不够的,需要再加一个版本号,即有线程操作过共享变量,就让版本号 +1。

	/**
	* 预设初始值: "A"
	* 预设版本号:0,也可以设置其他数,规则是自定义的
	*/
    static AtomicStampedReference<String> ref = new AtomicStampedReference<>("A",0);
    
    public static void main(String[] args) {
        String prev = ref.getReference();
        int stamp = ref.getStamp();
        log.debug("版本号为:{}",stamp);
        other();
        sleep(1000);
        log.debug("other方法执行结束,版本号:",stamp);
        /**
		* 传入的值: prev
		* 想要更新的值:"C"
		* 带入的版本号:stamp
		* 修改成功后修改的预设标记值:false
		* 
		* 判断传入的prev是否等于预先设置的初始值,并且判断版本号是否等于初始的版本号,是则修改,修改后还把版本号+1,否则不予修改。
		*/
        log.debug("change A->C: {}", ref.compareAndSet(prev,"C",stamp,stamp+1));
    }

    public static void other() {
        new Thread(() -> {
            int stamp = ref.getStamp();
            log.debug("change A->B {}", ref.compareAndSet(ref.getReference(), "B", stamp, stamp+1));
        },"线程t1").start();
        sleep(500);
        new Thread(() -> {
            int stamp = ref.getStamp();
            log.debug("change B->A {}", ref.compareAndSet(ref.getReference(), "A", stamp, stamp+1));
        },"线程t2").start();
    }

在这里插入图片描述
发现主线程的修改失败了,达到了最初的需求。

此处再引入一个方法:AtomicMarkableReference

AtomicMarkableReference:相对于AtomicStampedReference,AtomicMarkableReference只记录一个boolean值,假如初始值传true,有其他线程操作过,就改为false,这样就不需要记录版本号了。

* 预设初始值: "A"
	* 预设标记值:true,也可以为false,规则是自定义的
	*/
    static AtomicMarkableReference<String> ref = new AtomicMarkableReference<>( "A",true); 
    
    public static void main(String[] args) {
        String prev = ref.getReference();
        other();
        sleep(1000);
        /**
		* 传入的值: prev
		* 想要更新的值:"C"
		* 带入的预设标记值:true
		* 修改成功后修改的预设标记值:false
		* 
		* 判断传入的prev是否等于预先设置的初始值,并且判断标记是否为true,是则修改,修改后还把标记改为fasle,否则不予修改。
		*/
        log.debug("change A->C: {}", ref.compareAndSet(prev,"C",true,false)); 
    }

    public static void other() {
        new Thread(() -> {
            log.debug("change A->B {}", ref.compareAndSet(ref.getReference(), "B", true,false));
        },"线程t1").start();
        sleep(500);
        new Thread(() -> {
            log.debug("change B->A {}", ref.compareAndSet(ref.getReference(), "A", true,false));
        },"线程t2").start();
    }

7. 对 LockSupport 的看法。

LockSupport 是 jdk 中用于阻塞的原语,AQS: AbstractQueuedSynchronizer 就是通过调用 LockSupport .park() 和 LockSupport .unpark() 实现线程的阻塞和解除阻塞的。

LockSupport.park():让线程阻塞;
LockSupport.unpark(线程t):唤醒阻塞的线程 t;


8. 谈谈 Lock 锁底层实现原理。

Lock 锁和 synchronized 功能是一样的,明显的区别就是 Lock 锁底层是 c++ 语言写的,synchronized 底层是 java 写的。

Lock 底层基于 AQS + CAS + LockSupport 锁实现。

  • AQS 底层原理:线程获取锁时,会记录一个状态,如果该锁已被其他线程获取,状态就为0,未被获取状态为1。其他未获取到锁的线程会被装在一个“容器”里面,这个容器在 AQS 里面就是一个双向链表。

三、ThreadLocal 相关

1. 谈谈对 ThreadLocal 的理解。

ThreadLocal 提供了线程本地变量,它可以保证访问到的变量属于当前线程,每个线程都保存有一个变量副本,每个线程的变量都不同。ThreadLocal 相当于提供了一种线程隔离,将变量与线程绑定。Threadlocal 适用于在多线程的情况下,可以实现传递数据,实现线程隔离。
Threadlocal 基本API:

  • New Threadlocal():创建 Threadlocal;
  • set(): 设置当前线程绑定的局部变量;
  • get(): 获取当前线程绑定的局部变量;
  • remove():移除当前线程绑定的变量;

2. ThreadLocal 与 Synchronized 区别。

Synchronized 与 Threadlocal 都可以实现多线程访问,保证线程安全的问题。

  • Synchronized 当多个线程竞争到同一个资源的时候,最终只能有一个线程访问,采用时间换空间的方式,保证线程安全。
  • Threadlocal 在每个线程中都自己独立的局部变量,空间换时间,线程之间相互隔离,相比来说 Threadlocal 效率比 Synchronized 更高。

3. 谈谈强、软、弱、虚引用区别。

前言:

在 JDK1.2 以前的版本中,当一个对象不被任何变量引用,那么程序就无法再使用这个对象。也就是说,只有对象处于可触及状态,程序才能使用它。这就像在商店购买了某样物品后,如果有用就一直保留它,否则就把它扔到垃圾箱,由清洁工人收走。一般说来,如果物品已经被扔到垃圾箱,想再把它捡回来使用就不可能了。


但有时候情况并不这么简单,可能会遇到可有可无的"鸡肋"物品。这种物品现在已经无用了,保留它会占空间,但是立刻扔掉它也不划算,因为也许将来还会派用场。对于这样的可有可无的物品:如果家里空间足够,就先把它保留在家里,如果家里空间不够,即使把家里所有的垃圾清除,还是无法容纳那些必不可少的生活用品,那么再扔掉这些可有可无的物品。


在Java中,虽然不需要程序员手动去管理对象的生命周期,但是如果希望某些对象具备一定的生命周期的话(比如内存不足时 JVM 就会自动回收某些对象从而避免 OutOfMemory 的错误)就需要用到软引用和弱引用了。


从Java SE2 开始,就提供了四种类型的引用:强引用、软引用、弱引用和虚引用。Java中提供这四种引用类型主要有两个目的:第一是可以让程序员通过代码的方式决定某些对象的生命周期;第二是有利于 JVM 进行垃圾回收。

  • 强引用:我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。比如下面这段代码中的 object 和 str 都是强引用:

    Object object = new Object();
    String str = "StrongReference";
    

    如果一个对象具有强引用,那就类似于必不可少的物品,不会被垃圾回收器回收。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不回收这种对象。

public class StrongReference {
	public static void main(String[] args) {
		new StrongReference().method1();
	}
	public void method1(){
		Object object = new Object();
		Object[] objArr = new Object[Integer.MAX_VALUE];
	}
}

结果:

在这里插入图片描述
如果想中断强引用和某个对象之间的关联,可以显示地将引用赋值为 null,这样一来的话,JVM 在合适的时间就会回收该对象。

  • 软引用:软引用在 Java 中用 java.lang.ref.SoftReference 类来表示,当系统内存充足的时候,不会被回收;当系统内存不足时,它会被回收,软引用通常用在对内存敏感的程序中,比如高速缓存就用到软引用,内存够用时就保留,不够时就回收。

    String str = "test";
      SoftReference<String> stringSoftReference = new SoftReference<>(str);
    

  • 弱引用:弱引用也是用来描述非必需对象的,当 JVM 进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在java中,用java.lang.ref.WeakReference类来表示。

    String str = "test";
      WeakReference<String> weakReference = new WeakReference(str);
    

弱引用与软引用的区别在于:弱引用的对象拥有更短暂的生命周期,在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。所以被软引用关联的对象只有在内存不足时才会被回收,而被弱引用关联的对象在 JVM 进行垃圾回收时总会被回收。


  • 虚引用:虚引用基本用不到,虚引用需要 java.lang.ref.Phantomreference 类来实现。顾名思义,虚引用就是形同虚设。与其它几种引用不同,虚引用并不会决定对象的生命周期。

4. ThreadLocal 内存泄露问题。

什么是内存泄漏问题:内存泄漏表示程序员申请了内存,但是该内存一直无法释放。
内存溢出问题:申请内存时,发现申请内存不足,就会报错内存溢出问题。

演示内存泄漏问题:

    public static void main(String[] args) {
        ThreadLocal<String> stringThreadLocal = new ThreadLocal<>();
        stringThreadLocal.set("zhangsan");
        stringThreadLocal = null;
        Thread thread = Thread.currentThread();
        System.out.println(thread);
    }

对此代码进行打断点调试:即使把 stringThreadLocal 赋值为 null,在threadLocals底下,发现缓存的字符串"zhangsan"也依旧存在。

在这里插入图片描述

ThreadLocal 内存泄漏大致为这样的:

ThreadLocal 本身不存储数据,它使用了线程中的 threadLocals 的属性,这个 threadLocals 是一个在 ThreadLocal 中定义的 ThreadLocalMap 对象,当调用 ThreadLocal 的 set 方法时,就把ThreadLocal 自身的引用 this 当作 key,用户传入的值当作 value 存到线程的 ThreadLocalMap 中,在 ThreadLocalMap 中有一个 Entry 对象,它的 key 就是 ThreadLocal类型,value 是 Object 类型。由于 ThreadLocal 是弱引用的,所以当外部没有强引用指向它的时候,它就会被gc回收,导致Entry的key为空,如果value没有被外部强引用的话,那么value就永远不会被访问,由于value是强引用而非弱引用,所以value不会被gc回收,所以就出现了内存泄漏的问题发生。


5. 如何避免 ThreadLocal 内存泄露?

避免内存泄漏有如下两种方法:

  • 每次使用完 ThreadLocal 后调用 remove() 方法清除数据。
  • 将ThreadLocal 变量尽可能定义成 static final,这样就可以避免频繁去创建 ThreadLocal 实例

四 、消息队列相关

1. 在项目哪些地方会用到 MQ?

在客户端 http 请求服务端时,如果业务较复杂,服务端由于要处理过多的业务逻辑,因此使用同步调用的话会导致服务端响应时间过长,使用户的体验感降低。因此使用多线程或者 MQ 可以异步实现服务调用,从而提升服务端的响应效率。


eg1:比如网络平台的借钱,首先要填写自己的个人信息,平台根据你的信息评估你的借钱额度,比如有查询你的征信,是否信用良好,查询你的名下是否有公司、房产、车产等信息。这些都是调用其他平台的接口,如果使用同步的方式,会非常耗时,一般的网络平台都是你填写完毕一提交就会得出额度,响应时间非常快。

eg2:电商平台注册会员,步骤大致如下:
在这里插入图片描述
使用同步操作的话大致是7秒。对于步骤2和步骤3,都存在不确定因素,对于发送短信,可能耗时3秒,也可能耗时更多,因此采用异步的方式后就不用考虑发送短信的耗时。

  • 使用 MQ 异步发送优惠卷;
  • 使用 MQ 异步发送短信;
  • 使用 MQ 异步扣减库存;
  • 使用 MQ 异步审核平台的贷款金额;

总之将执行比较耗时的代码操作,交给 MQ 异步实现接口。


2. 为什么使用 MQ 而不是多线程?

使用 MQ 的话会有很多好处,比如代码解耦、流量削峰。

  • 1、代码解耦:比如电商平台注册会员,用户提交信息过后,会进行如下操作:

    • 向会员表中插入数据;
    • 调用短信服务向用户发送短信提醒;
    • 给用户发送一些新人优惠券;

如果采用多线程设计的话,三个步骤分别为三个线程,假如步骤一执行成功后服务就宕机了,因此步骤二和步骤三都会执行失败,但是采用 MQ 的话由于他们是两个服务互不影响,因此刚好能解决这一问题,所以采用 MQ 更好。

  • 2、流量削峰,抗高并发:假如多个人同时开始注册会员,采用多线程的话就会创建很多线程,但是采用 MQ 的话就会把这部分线程的任务转移给 MQ 处理,从而减轻接口的负担。

3. MQ 与多线程实现异步有什么区别?

  • 多线程方式实现异步可能会消耗到我们的 cpu 资源,可能会影响到业务代码执行,会发生 cpu 竞争的问题;
  • MQ 实现异步是完全解耦,将业务代码和异步任务拆分,适合于大型互联网项目;
  • 小的项目可以使用多线程实现异步,大项目建议使用 MQ 实现异步;

4. MQ 如何避免消息堆积的问题?

生产背景:生产者投递消息的速率与消费者消费的速率完全不匹配。生产者投递消息的速率 > 消费者消费的速率,导致消息堆积在 mq 服务器中,因此产生消息堆积的问题。
需要注意的是,rabbitmq 消费者如果消费成功的话,消息会被立即删除,kafka 或者 rocketmq 消息消费如果成功的话,消息则不会被立即删除。

  • 解决办法:

    • 提高消费者消费的效率(对消费者实现集群);
    • 消费者应该以批量的形式获取消息,减少网络传输的次数;

5. MQ 如何保证消息不丢失?

要保证消息不丢失,需要保证生产者投递消息到 mq 服务器必须成功,也必须要保证消费者从 mq 服务器消费消息必须成功。

  • 站在生产者的角度,如果生产者投递消息的过程中 mq 服务器宕机了,则生产者可以把消息记录在第三方的数据库中,如mysql、redis,后期再由定时任务去定时把数据库中的消息投递到 mq 服务器;
  • 站在 MQ 服务器端的角度,可以把消息持久化到硬盘当中;
  • 站在消费者的角度:必须要进行消息确认,在 rabbitmq 中,只有消费者消费成功才会把消息删除,在 rocketmq 或 kafka 中,必须进程offset;

6. MQ 如何保证消息顺序一致性问题?

MQ 服务器集群或者 MQ 采用分区模型架构存放消息,每个分区对应一个消费者消费消息来解决消息顺序一致性问题。
核心办法:消息一定要投递到同一个 mq、同一个分区模型、最终被同一个消费者。消费根据消息 key % 分区模型总数

1、大多数的项目是不需要保证 mq 消息顺序一致性的问题,只有在一些特定的场景可能会需要,比如 MySQL 与 Redis 实现异步同步数据。
2、所有消息需要投递到同一个 mq 服务器,同一个分区模型中存放,最终被同一个消费者消费,核心原理是设定相同的消息 key,根据相同的消息 key 计算 hash 存放在同一个分区中。
3、如果保证了消息顺序一致性有可能降低我们消费者消费的速率。


7. 什么是消息幂等性与非幂等性?

  • 对于消息幂等性:就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次请求而产生副作用。在 MQ 当中,消息幂等性就是同一消息,无论被消费多少次,其最终的结果都是一致的。
  • 非幂等性则相反,对于同一消息,多次消费可能会产生多次不同的结果。

8. MQ 如何保证消息幂等性问题?

要保证消息幂等性有如下几种方式:

  • 方式一:给每条消息建立唯一标识,消费者在处理消息的时候先根据唯一标识判断该消息是否已被处理,如果处理过则忽略,避免重复处理。
  • 方式二:在消费者处理消息之前,先检查系统的状态,如果发现系统系统已经处于某一个预期的值,那么就不需要再次处理该消息了。



五、java基础相关

1. java中==和equals()的区别:

在讲解之前,先做以下铺垫:java中有哪些数据类型?以及他们的存储位置?

  • java中的数据类型:
    • 基本数据类型:整数类型(byte,short,int,long)、浮点类型(float,double)、字符型(char)、布尔型(Boolean)。
    • 引用数据类型:类(class)、接口(interface)、数组。
  • 存储位置:
    • 基础数据类型:存储在常量池中(JDK8之后去除了元方法区,改为存在堆内存中的元空间)。
    • 引用数据类型:存储于堆中。

请添加图片描述

之所以做出上述铺垫,是因为在java中,== 比较的内容和数据所处的位置相关。

1.对于==,如果是八大基本数据类型,比较的是常量池中的值是否相等;如果是引用数据类型(类、接口、数组),比较的是对象的内存地址(每new一个对象,其引用地址都是不一样的)。

2.对于eqlals()方法,常用于比较对象是否相等(String也属于对象)。Object中默认的equals()方法比较的是两个对象的地址是否相等,当然,我们也可以重写这个equals()方法,java中equals方法也允许我们根据需要自定义比较方法。


2. hashcode相等的两个对象,== 一定相等吗?equals() 一定相等吗?

// Object中默认的hashCode()和equals()
public native int hashCode();

hashcode:是 Object 的一个默认方法,由 native 修饰,内部是由 c++ 编写的。hashcode其实就是对象的内存地址随机映射的一个整数值,既然是随机映射的,也就存在不同内存地址的对象他们的 hashcode 可能相等。

对于 hashcode 相等的两个对象,由于 == 和 equals() 用于比较引用数据类型时都是比较内存地址是否相等,因此 hashcode 相等的两个对象 == 和 equals() 都有可能相等,也有可能不相等。


3. 反之两个对象用 == 和 equals() 比较相等,他们的 hashCode 一定相等吗?

equals() 相等说明两个对象的内存地址相等,也就是同一个对象,因此他们的 hashcode 一定相等。


4. 为什么重写equals()方法后一定要重写hashCode()方法?

​ 讲解之前,先思考,哪些场景下需要重写equals()和hashCode()方法?

在我们使用HashMap时,如果key为一个对象,如何保证这个对象的唯一性?同样的问题,在使用HashSet时,由于set集合的无序不重复特性,如何保证存入对象的唯一性?

​ 此处以HashMap举例,首先我们需要清楚HashMap的数据结构,HashMap底层采用了数组+链表的结构,其中jdk1.8以后还加入了红黑树。简图如下:

请添加图片描述

​ 由上图得出,HashMap其实就是一个链表数组,我们的数据实际是存放在链表上,在我们使用map.put()方法时,实际上是先采用hashCode()方法得出哈希值,再通过哈希值计算出下标位置(就是存储在数组哪个下标底下),当数组下标位置相同而value不同时,这种情况叫做“哈希冲突”,解决哈希冲突的方法有很多,此处不一一赘述。

​ 言归正传,回到最初的问题,我们为什么要重写equals()和hashCode()方法?

​ 通过一个案例来说明,我们创建两个Person对象,两个对象id都是1,名字都是张三。存入map后打印两个对象key的value。

// 假如现有一个Person类
Person p1 = new Person("1","张三");
Person p2 = new Person("1","张三");

// 以person为key存入map
Map<Person, Object> map = new HashMap<>();
map.put(p1, "我是person1");
map.put(p2, "我是person2");
System.out.println(map.get(p1)); // 打印什么? 我是person1
System.out.println(map.get(p2)); // 打印什么? 我是person2

​ 此处思考,为啥两个key打印的结果不同?按理说两个对象相同,打印结果也应该相同才对。在上面的==与equals问题中也提到了一点:Object中默认的equals()方法比较的是两个对象的地址是否相等,而默认的hashCode()方法返回的是对象的内存地址转换成的一个int整数,实际上指的也是内存,两个方法都可以理解为比较的是内存地址。

// Object中默认的hashCode()和equals()
public native int hashCode();

public boolean equals(Object obj) {
    return (this == obj);
}

因此就算p1、p2的id和name都相同,也是两个不同的对象,要想保证这个person对象的唯一性,就只能重写equals()和hashCode()方法。重写过后如果新建了多个对象,这些对象的属性都一致的话就会判断为同一个对象。


5. 谈谈 final 关键字

java 中 final 可以用于修饰类、成员变量(全局变量)、成员方法。

  • final 修饰的类,不能被继承,也就是没有子类。
  • final 修饰的成员变量是不可变的,如果成员变量是基本数据类型,初始化之后成员变量的值不能被改变,如果成员变量是引用类型,那么它只能指向初始化时指向的那个对象的地址,并且指向的地址不能被改变,但是对象中的内容是允许改变的。
  • final 修饰的方法:final 修饰的方法不可以被子类的方法重写。

6. 谈谈 static 修饰符的用法

static 可用于修饰字段(变量或常量)、修饰方法、修饰代码块、修饰导入包、修饰静态内部类。

  • 修饰字段(变量或常量):
    • static 修饰的变量叫做静态变量,使用较少:对于类中的静态变量,在创建类对象时静态变量会先被初始化,随后是普通变量,并且再次创建类对象时,静态变量不会被再次初始化。
    • static 修饰的常量叫做静态常量,使用较多:对于类中修饰的静态常量,不能被改变,比如 Math 类中定义的 PI。
      public static final double PI = 3.14159265358979323846;
      
  • 修饰方法:被 static 修饰的方法,称为静态方法。静态方法是不用创建类对象就可以执行的方法,比如 Thread 中的 sleep() 方法。
  • 修饰代码块:静态代码块主要用在类加载过程中执行一些初始化操作,因为静态代码块在初始化时优先级最高,静态代码块 > 静态方法 > 构造方法 > 普通方法 ,比如初始化静态变量、使用 JDBC 连接数据库加载驱动的时候会用到静态代码块。
    // 初始化静态变量
    private static int id = 1;
    static {
    	Random random = new Random();
    	id = random.nextInt(1000); // 随机生成一个小于1000的随机整数
    }
    
  • 修饰导入包:当我们调用被 static 修饰的方法时,比如 Math 类中的 max() 方法,只能使用 Math.max(1,2); 但是使用静态导入包后,可省去类名。
    Math.max(1,2); // 头部没有静态引入Math类
    // =======================================
    import static java.lang.Math.*;
    max(1,2); // 头部静态引入了Math类
    
  • 修饰静态内部类:当需要把在一个类隐藏在另一个类中时,就可以使用 static 来实现,并且用 static 修饰的这个类叫做静态内部类。
    public  class TestFinally {
        private static int id = 1;
        private String name;
        
    	 // 静态内部类
         static class TestStaticClass {
             int show () {
                 return id;
             }
         }
    }
    

7. java中String与new String的区别

String 对象的创建:

  • String str1 = “abc”;是存储在常量池中的,并且常量池中没有重复的元素,如果常量池中存在这个 “abc” 字符串,就直接将地址赋值给 str1,如果没有就先创建一个再把地址赋值给变量。
  • String str2 = new String(“abc”);是使用构造方法在堆内存中创建对象,它的执行步骤是:
    • 先在常量池中判断是否存在 “abc” 这个字符串常量;
    • 存在则直接引用已存在的 “abc”,不存在则在常量池中创建一个值为 “abc” 的字符串对象;
    • 然后在堆内存中通过 new 关键字创建字符串对象,再赋值给 str2;

总结:String str1 = “abc”;要么创建0个对象,要么创建1个对象。String str2 = new String(“abc”);要么创建1个对象,要么创建2个对象。


8. String、StringBuffer、 Stringbuilder 的什么区别

String、StringBuffer、StringBuilder的区别:

StringStringBufferStringBuilder
执行速度最差其次最高
线程安全线程安全线程安全线程不安全
使用场景少量字符串操作多线程环境下的大量操作单线程环境下的大量操作
  • String 存放在常量池中,以 final 修饰符修饰,所以 String 是不可变的,因此在多线程环境下也是安全的,因为它的不可变特性,所以在进行字符串拼接的时候效率非常底下。
  • StringBuffer 是可变字符串,在进行字符串拼接的时候用到了 synchronize 关键字,保证字符串拼接同步处理,所以线程安全,效率也相对 StringBuilder 要低,但远比 String 效率高。
  • StringBuilder 也是可变字符串,在进行字符串拼接的时候没有用到 synchronize 关键字,因此线程不安全,但是效率是最高的。

9. 介绍一下包装类的自动装箱与自动拆箱

Java中有八大基本数据类型,每一个类型都对应一个包装类:

基本数据类型包装类
byteByte
shortShort
int Integer
longLong
floatFloat
doubleDouble
booleanBoolean
char Character

基本类型 —>自动转换 —> 包装类对象,叫做自动装箱;
包装类对象 —> 自动转换 —> 基本类型,叫做自动拆箱;

举例:

int a = 5; // 存储在栈内存中
Integer b = Integer.valueOf(a); // 装箱,在堆内存中创建Integer对象

并且自动拆箱的时候不会产生新的对象,只是相同对象在栈和堆中的引用转换。


10. 错误(Error)与异常(Exception)

Java 中有错误和异常,他们的父类都是 Throwable。

  • 错误就是 Java 虚拟机在运行过程中所遇到的不可恢复的严重问题,比如有栈溢出(StackOverFlowError)、内存溢出(OutOfMemoryError)等等;
  • 异常根据异常发生的原因可以分为运行时异常和非运行时异常,运行时异常就是程序在运行过程中可能会出现的异常,主要有空指针,数组越界,分母为0等,非运行时异常有 IO异常、ClassNotFoundException(类未找到)、SQL异常等。

11. 什么时候用多线程,为什么要设计多线程

使用多线程有以下优点:

  • 提高响应速度:多线程可以在等待某个耗时任务完成之前异步执行其他任务,可以大大提升响应速度。
  • 提高 CPU 使用率:对于多核 CPU 的电脑来说,多线程可以充分发挥多核 CPU 的优势,也能在整体上提升效率。
  • 简化程序结构:用多线程可以将复杂的任务分解为多个简单的线程执行,减少代码之间相互依赖,降低代码耦合,使代码简洁。

但也有如下缺点:

  • 创建过多的线程和进行过多的上下文切换也会浪费内存空间和影响效率。
  • 多线程访问共享数据有可能会导致死锁和数据不一致问题。

12. 说说对反射的理解

反射就是在程序运行期间动态的获取对象的属性和方法的功能叫做反射。在程序运行期间,对于任意一个类、任意一个对象,都能知道它所有的方法和属性。
获取Class对象方式有三种:对象名.getClass();类名.class;Class.forName(“类名”);
反射的优缺点: 优点:运行期间能够动态的获取类,提高代码的灵活性。 缺点:性能比直接的Java代码要慢很多。 应用场景:spring的xml配置模式,以及动态代理模式都用到了反射。


13. 说说线程生命周期的几种状态

对于线程的状态有两种说法,一种说法是有5种状态,一种说法是有6种状态。

对于5种状态的说法:

站在操作系统的层面,对于任意一个线程,都有5种说法,分别是:创建、就绪、运行、阻塞、终止。

  • New:创建状态,当Thread 类被实例化后,线程就进入了新建状态。
  • Runnable:就绪状态,当线程对象调用了 start() 方法后,这时候线程处于就绪状态,但并不是就绪状态就一定有资格运行,而是需要 CPU 时间片的调度后才能进入运行状态。
  • Running:运行状态,线程对象调用了 start() 方法并且线程获得了 CPU 时间片就处于运行状态。
  • Blocked:阻塞状态,线程在进行一些耗时的操作时,比如耗时的输入/输出、遇到同步锁等,会先放弃 CPU 的使用权,暂停运行,待耗时操作结束后,要想再次回到运行状态,只能是先回到就绪状态,再等待 CPU 的调度。
  • Dead:终止,也叫死亡状态,当线程执行完毕或者退出了 run() 方法,该线程结束生命周期。

站在 java 代码的层面,有6种状态:

  • New:新建状态,当Thread 类被实例化后,线程就进入了新建状态。
  • Runnable:可运行状态,当线程对象调用了 start() 方法后,这时候线程处于可运行状态,可运行状态又在内部分成了两部分,一部分是就绪,一部分是运行,并且线程可以在这两种状态之间进行切换。
  • Blocked:阻塞状态,和5种状态的说明一致,也是线程在进行一些耗时的操作时,暂时先放弃了 CPU 的使用权。
  • Waiting:等待状态,当处于运行中的线程调用了wait()、join()等方法后,当前线程就处于了等待状态,等待状态的线程只能等待再次得到 CPU 的调度后,才能恢复到可运行状态。
  • Timed Waiting:计时等待状态,和等待状态类似,区别就是计时等待状态有一个等待的时间,等待的时间到后也会进入可运行状态。
  • Terminated:被终止状态,当线程的 run() 方法执行完毕,或者 main 主线程执行完毕后,就处于终止状态。

14. 说说 wait() 和 sleep() 的区别

wait() 和 sleep()既有相同点也有不同点:

  • 相同点:sleep() 和 wait() 都可以暂停线程的执行。
  • 不同点:
    • sleep() 是 Thread 类中的静态方法,wait() 是Object类中的普通方法。
    • sleep()在多线程情况下不会释放锁,只是先休眠一段时间,时间到后自动恢复正常;wait() 常用于线程间通信,会进行锁释放,并且需要通过 notify() 或 notifyAll() 来唤醒线程。

15. 说说 synchronized 和 ReentrantLock 有什么区别

  • 共同点: synchronized 和 ReentrantLock都是 java 里面用来实现线程同步的机制,都可以用来解决线程安全问题。

  • 不同点:

    • 第一点:synchronized 是 java 的一个关键字,是jvm层面的一个同步锁;ReentrantLock 是在 jdk 1.5之后concurrent.locks 包下的一个类,是 api 层面的一个同步锁。
    • 第二点:synchronized 是隐式锁,使用的时候不需要手动获取或释放锁,只需要在方法上直接使用这个关键字就可以了;而 ReentrantLock 是显示锁,使用的需要手动获取锁,释放锁。
    • 第三点:synchronized 是一个不可重入锁,同一个线程在获得锁的情况下再次获取锁只能等待锁释放;而 ReentrantLock 是可重入锁,也就是可以多次获得锁,同一个线程再次获得锁时只需要将获得锁的次数进行 +1 操作即可。

16. volatile 是什么,说说对 volatile 的理解

volatile 是 java 语言的一个关键字,可以用于修饰变量,变量的值是存储在主存中的。在多线程环境下,为了提高线程获取变量值的速度,JIT 编译器(即时编译器)会先将主存中的值缓存到当前线程工作区的高速缓存中,线程再到高速缓存中去获取变量值,但是当其他线程修改了主存中的变量值后,当前线程工作区的高速缓存中的变量值并没有得到同步修改,因此会导致线程获取的变量是一个错误的变量。


把变量用 volatile 关键字进行修饰过后,线程就不会把变量值缓存到自己的高速缓存中,而是直接去主存中获取变量的值,这样就解决了线程之间获取变量值不一致的问题,从而保证了线程之间的可见性和数据一致性。


除此之外,volatile 还能保证被修饰的变量读写操作的有序性,这个有序性指的是在多线程环境下,线程进行读操作能读取到最新的值,进行写操作也能立即写到主存当中,并且其他线程获取变量最新值时,也是去主存中获取,而不是从自己的缓存中获取。


17. 对 Java 集合的了解

在 java 中集合分为两大类,Collection 集合和 Map 集合,Collection 集合又分为 List、Set、Queue,Map 集合又分为 HashMap 和 TreeMap。大致图解如下:

在这里插入图片描述

  • Lsit大类:不管是 ArrayList 还是 LikedList,它们都是一条链式结构。

    • ArrayList:ArrayList的底层是数组,并且数组是一长串连续的空间,因此在内存中也需要一大块连续的空间用于存储,对于数组来说,插入和删除的效率都比较慢,假如在数组中随机一个位置插入和删除一个元素,由于数组的连续特性,在这个位置后面的所有元素都需要挪动,所以效率较慢;但是对于获取元素的效率非常高,只要通过下标可以直接获取,但对于查找具体的元素值,还是比较慢的,需要进行循环遍历,一个个比较。
    • LikedList:LikedList的底层是链表,链表不需要连续的存储空间,它是将一个个零散的空间串联起来的,对于链表来说,插入和删除的效率较高,因为只要将删除节点前一个元素的指针,指向删除节点的下一个元素即可,但是链表的查询效率很低,不能像数组那样通过下标获取,只能一个个遍历。
  • Set大类:Set集合的特性是无序不重复,它能保证存入元素的唯一性。

    • HashSet:HashSet 的底层其实是 HashMap,它就相当于是 HashMap的key,不可以重复,但是可以存储 null。
    • TreeSet: TreeSet 从名字上看底层的数据结构应该是树,但它的底层也是 Map,是TreeMap,它也不可以插入重复元素,但HashSet和TreeSet最大的区别就是,HashSet的插入顺序和存储顺序无关,TreeSet的存储顺序默认按照自然顺序进行排序(比如数的从大到小等)。


  • Map 大类:map 大类最典型的就是 HashMap 和 TreeMap。关于 hashMap的讲解在下面进行详细讲解。


六、java基础之 HashMap

1. HashMap 与 HashTable 的区别

HashMap 和 HashTable 都是java中哈希表基于 Map 集合的实现类,笼统的说他们都是 Map 集合,但是他们采用的哈希算法和数据结构有所不同。

  • 数据结构不同:

    • HashMap:HashMap 底层采用数组+链表实现,在 jdk 1.8之后又加入了红黑树,当发生哈希冲突时,会使用链表或红黑树来解决冲突,HashMap中有一个负载因子的概念,默认情况下这个负载因子是 0.75,如果当前元素个数大于容量和负载因子的乘积时,就会进行扩容处理,扩容一般按照两倍进行扩容。
    • HashTable:Hashtable 底层也采用数组+链表实现,当发生哈希冲突时,使用链表来解决冲突,但与 HashMap 不同的是,Hashtable 不管是 jdk 1.7之前还是1.8之后,都没有引入红黑树。Hashtable 他的负载因子也是 0.75,当当前元素个数大于容量和负载因子的乘积时,会进行扩容处理,扩容一般按照两倍 + 1进行扩容。

  • 线程安全性不同:

    • HashMap:HashMap在进入 put 元素时,put方法没有同步锁,是线程不安全的,但是他的效率高。
    • HashTable:HashTable 的 put 方法 有synchronized 进行修饰,因此是线程安全的,但是效率较低。

  • 插入 key 为 null 时不同:

    • HashMap:HashMap的 key 和 value 都可以进行插入 null,并且插入 null 时这个元素是插入在数组的第一个位置,也就是下标为 0 的链表上。
    • HashTable:HashTable 的 key 和 value 都不可以为 null,当 key/value 为 null 时,会抛出空指针异常。

关于线程安全的 map 集合,其实更推荐使用 ConcurrentHashMap,它既是线程安全的,效率也比 HashTable 要高。

2. HashMap 如何解决 Hash 冲突问题

HashMap 解决 Hash 冲突主要有两种,开放寻址法和链地址法。

  • 开放寻址法:开发寻址法就是,当发生冲突的时候,继续往后进行查找,如果有空位置,就存储在空位置上。对于开放寻址法,也有很多种方法,比如线性探测法、二次探测法等等。

    • 线性探测法:线性探测法就是当发生冲突时,往后进行查找,如果有空位置,就存储在空位置上,如果没有空位置,就继续往后查找。
    • 二次探测法:二次探测法与线性探测的区别就是,当发生冲突时,他的查找方式为:+1,-1,+4,-4…i^2,- ( i ^2);就是先向后查找一个位置看是否为空,不为空的话,再向后查找一个位置,再次判断,然后再向前查看 4 个位置,向后查找 4个位置…这样一直查找下去就是二次探测法。
  • 链地址法:当发生冲突时,是把冲突的元素存在链表中,形成数组 + 链表的形式,hashMap就是采用这样的形式。

除了这两种方法,还有其他的一些方法,此处就不讲解了,因为不常用。


3. HashMap 如何实现数组扩容问题

当集合中元素个数超过负载因子(0.75) * 原始数组长度时,会触发扩容操作,并且扩容后会遍历原数组,将每个元素重新计算哈希值,然后重新插入新数组中;如果发生哈希冲突,就会以链地址法的方式形成一条链表。但是数组的扩容会有时间开销从而影响效率,因此应当合理的设置负载因子和初始数组长度。


4. HashMap 底层采用单链表还是双链表

答案是单链表,在 jdk 1.7之前是链表,在 jdk 1.8之后,当链表长度超过 8 后,就会将这条单链表转换为红黑树,红黑树类似于是一种平衡二叉搜索树,相对于链表,对红黑树进行遍历的时候时间复杂度会更低,因为链表查找的时间复杂度总是为 O(n),但红黑树一定比 O(n) 要小。并且在链表上插入数据时,总是在尾端进行插入,这样能保证数据的插入顺序。


5. HashMap 根据 key取余的时间复杂度

答案是 O(1),因为只需要知道数组的长度后,利用 key 的哈希函数对数组长度进行一次取余计算,就可以得出数组下标的位置。


6. HashMap 在 jdk 1.7 中是如何出现多线程扩容死循环问题的?

在jdk 1.7中,hashMap 中的链表是采用头插法进行插入,并且集合元素 > 负载因子 * 初始长度(默认是16)后,会进行 2 倍扩容,并且扩容后节点元素会重新计算哈希值插入新链表。

在这里插入图片描述

并且重组链表后原始链表节点会进行倒序。当在多线程情况下,多个线程在进行扩容时,有可能会发生死循环的问题。假如现在有两个线程 T1、T2 在进行扩容,扩容时每个线程都会遍历原数组,并且每个线程都有两个指针,分别指向链表上的第一个节点 e,和下一个节点 e.next。当线程 T1、T2 都指向完毕后,线程 T1 由于 CPU时间片到暂时休眠,而线程 T2 在正常扩容,待线程 T2 扩容完毕后,线程 T1 恢复,进行继续扩容,这时线程 T1 指向的节点不会改变,因此原本指向的节点顺序已经变了,这时就有可能会形成环状的死循环。

在这里插入图片描述

7. ConcurrentHashMap 的底层实现原理?

  • ConcurrentHashMap 的整体架构:

    在 jdk 1.8 中,ConcurrentHashMap 和 hashMap 一样,也是采用数组 + 单链表 + 红黑树组成的,当我们创建 ConcurrentHashMap 实例的时候,默认会创建一个长度为 16 的数组,由于 ConcurrentHashMap 它本质上其实还是 hash 表,因此也存在 hash 冲突,并且和 hashMap 一样也是采用链地址法去解决 hash 冲突,当 hash 冲突较多的时候,会造成链表过长的问题,因此在 jdk 1.8 中当数组长度大于 64,并且链表长度大于 6 的时候会演变成一棵红黑树,从而提升查询的效率。

  • ConcurrentHashMap 的基本功能:

    ConcurrentHashMap 本质上也是 hashMap,因此在功能上也和 hashMap 一样,但是 ConcurrentHashMap 与 hashMap最本质的区别就是在 hashMap 的基础上实现了线程安全问题,在 jdk 1.8后,它是通过对 node 节点加锁来保证数据更新的安全性。

  • ConcurrentHashMap 在性能方面的优化:

    ConcurrentHashMap 在性能方面也做了优化,它在线程安全问题和并发性能之间做了平衡,在 jdk 1.7之前,整个哈希表被分为很多片段,它的锁范围是其中一个片段(segment),在 jdk 1.8以后,锁范围改为了数组中的某一个节点,也就是对链表之间进行加锁,锁的范围变得更小了。其次它引入了红黑树,红黑树的查询的时间复杂度是 O(log n),相对于单链表O(n)得到提升;在数组进行扩容时,ConcurrentHashMap 也采用了多线程对原始数据进行分片,分片之后让每个线程去单独负责某个片段的数据迁移,从而在整体上提升 ConcurrentHashMap 的效率。



七、MyBatis

1. 什么是 MyBatis?

  • MyBatis是一个半自动的对象映射关系框架(ORM框架),它的内部封装了原生的JDBC,我们使用 MyBatis 的时候不用再像 JDBC 那样去加载驱动、创建连接等等过程,只需要关注如何编写 sql 语句即可。
  • MyBatis 可以支持 xml 和注解两种方式去实现 sql 语句的编写,可以减轻我们手动编写 JDBC 代码、设置参数、获取结果集等等负担,大大提升开发效率,也可以将 java 代码和 sql 语句分离开来,方便程序员维护代码。

2. #{} 和 ${} 的区别是什么?

在 MyBatis 中,#{} 和 ${} 是用来进行参数传递和动态 sql 拼接的。但是这两种方式也存在差别。

  • 对于 #{},在进行参数传递时,MyBatis 会对传入的参数进行预编译处理,预编译处理后将参数值放入占位符中,可以有效的防止 sql 注入,最终生成的 sql 语句中的占位符会被直接替换成参数值。同时 #{} 还可以对参数进行类型转换,比如 java中的 String转换为 mysql 中的 varchar。
  • 对于 ${},在进行参数传递时,MyBatis 不会进行预编译处理,而是直接将参数拼接到 sql 语句中,因此有可能存在 sql 注入的安全问题,但是有时候又只能使用 ${},比如 order by 列名的时候。

综上所述,我们要谨慎使用 ${}。

3. 当实体类的属性名和表中的字段名不一致怎么办?

有两种解决方式:

  • 方式一:在 sql 语句中取别名。
  • 方式二:使用 resultMap 来一一映射字段名和实体类属性名。

4. Mybatis是如何将sql执行结果封装为目标对象并返回的?都有哪些映射形式?

有两种方式:

  • 方式一:使用 resultMap 标签,逐一对应数据库列名和对象属性名。
  • 方式二:在 sql 语句中加入别名,将别名写成对象的属性名。

有了列名与属性名的映射关系后,Mybatis 会通过反射技术创建对象,同时通过反射给对象的属性逐一赋值并返回。


5. 说一下 resultType 和 resultMap ?

resultType 和 resultMap 都是 MyBatis 中用于映射结果的方式。

  • 对于 resultType:resultType 指定了 SQL 语句查询返回的结果类型。可以是String、Integer等等,也可以是自定义的 Java 类,当查询结果是一个列或者一个类中的属性时,可以使用 resultType 作为返回类型。
  • 对于 resultMap:resultMap 可以手动映射查询结果集与 Java 对象之间的属性,当查询结果涉及多个对象中的属性时,可以使用 resultMap 映射,在 resultMap 中,主要有 id、result、 association 和 collection 四种标签。
    • id 标签用于映射主键列和 java 对象的属性,result 用于映射非主键列和 java 对象的属性。
    • association 和 collection 都用于定义关联关系,association 是一对一关联,将一个对象映射到另一个对象,比如对象的属性是另一个对象,使用 association 进行映射。collection 是一对多关联,当查询结果列中存在多个数据时,比如是一个 List 集合,集合中都是对象,则使用 collection 进行映射。












续:Java八股文总结(二):https://blog.csdn.net/weixin_44780078/article/details/131796843

  • 1
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值