Java八股文总结(一)

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

文章目录

    • 一、JUC相关
      • 1. 谈谈什么是线程池?
      • 2. 为什么需要使用线程池?
      • 3. 在哪些地方会使用到线程池?
      • 4. 线程池有哪些作用?
      • 5. 线程池的创建方式
      • 6. 线程池底层是如何实现复用的?
      • 7. java手写模拟线程池
      • 8. ThreadPoolExecutor核心参数有哪些?
      • 9. 线程池创建的线程会一直处于运行状态吗?
      • 10. 线程池队列满了,任务会丢失吗?
      • 11. 为什么阿里巴巴不建议使用 Executors ?
    • 二、JUC—锁
      • 1. 什么是悲观锁,什么是乐观锁?*
      • 2. 公平锁与非公平锁?
      • 3. 对 CAS 锁的理解?
      • 4. 模拟手写 CAS 锁。
      • 5. CAS 锁的优缺点。
      • 6. CAS 如何解决 ABA 的问题。
      • 7. 对 LockSupport 的看法。
      • 8. 谈谈 Lock 锁底层实现原理。
    • 三、ThreadLocal 相关
      • 1. 谈谈对 ThreadLocal 的理解。
      • 2. ThreadLocal 与 Synchronized 区别。
      • 3. 谈谈强、软、弱、虚引用区别。
      • 4. ThreadLocal 内存泄露问题。
      • 5. 如何避免 ThreadLocal 内存泄露?
    • 四 、消息队列相关
      • 1. 在项目哪些地方会用到 MQ?
      • 2. 为什么使用 MQ 而不是多线程?
      • 3. MQ 与多线程实现异步有什么区别?
      • 4. MQ 如何避免消息堆积的问题?
      • 5. MQ 如何保证消息不丢失?
      • 6. MQ 如何保证消息顺序一致性问题?
      • 7. MQ 如何保证消息幂等性问题?
    • 五、java基础相关
      • 一、java中==和equals()的区别:
      • 二、为什么重写hashCode()和equals()方法?

一、JUC相关

1. 谈谈什么是线程池?

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


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

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


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

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


4. 线程池有哪些作用?

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

5. 线程池的创建方式

  • 可缓存线程池: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>()));
}

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

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

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

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

线程池底层实现原理:
Java八股文总结(一)_第1张图片
线程池核心点:复用机制。

  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,由于上限太大,如果不断的存放线程任务就会不断的占用内存,最终可能会导致内存溢出。

Java八股文总结(一)_第2张图片

二、JUC—锁

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

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

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

    • 站在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();
    }

Java八股文总结(一)_第4张图片
发现主线程的修改失败了,达到了最初的需求。

此处再引入一个方法: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"也依旧存在。

Java八股文总结(一)_第5张图片

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:平台注册会员,流程图大致如下:
Java八股文总结(一)_第6张图片
使用同步操作的话大致是7秒。对于步骤2和步骤3,都存在不确定因素,对于发送短信,可能耗时3秒,也可能耗时更多,因此采用异步的方式后就不用考虑发送短信的耗时。

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

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


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

  • 1、异步处理:还是上述的那个注册会员例子,如果使用的是多线程发布短信,一旦插入会员数据的服务器宕机,短信服务也会宕机,因为他们是同一个服务,使用 MQ 的话就能实现解耦,也就是不属于同一个服务器。
  • 2、实现解耦;
  • 3、流量削峰(MQ可以实现抗高并发);

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

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

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

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

  • 解决办法:

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

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

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

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

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

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

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


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



五、java基础相关

一、java中==和equals()的区别:

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

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

Java八股文总结(一)_第7张图片

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

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

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

二、为什么重写hashCode()和equals()方法?

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

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

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

Java八股文总结(一)_第8张图片

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

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

​ 通过一个案例来说明,我们创建两个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对象的唯一性,就只能重写hashCode()和equals()方法。重写过后如果新建了多个对象,这些对象的属性都一致的话就会判断为同一个对象。


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

你可能感兴趣的:(java)