云计算百科
云计算领域专业知识百科平台

招银网络 java后端开发最新面试题

1. ArrayList和LinkedList的区别是什么?

ArrayList和LinkedList都是Java中常见的集合类,它们都实现了List接口。

  • 底层数据结构不同:ArrayList使用数组实现,通过索引进行快速访问元素。LinkedList使用链表实现,通过节点之间的指针进行元素的访问和操作。

  • 插入和删除操作的效率不同:ArrayList在尾部的插入和删除操作效率较高,但在中间或开头的插入和删除操作效率较低,需要移动元素。LinkedList在任意位置的插入和删除操作效率都比较高,因为只需要调整节点之间的指针。

  • 随机访问的效率不同:ArrayList支持通过索引进行快速随机访问,时间复杂度为O(1)。LinkedList需要从头或尾开始遍历链表,时间复杂度为O(n)。

  • 空间占用:ArrayList在创建时需要分配一段连续的内存空间,因此会占用较大的空间。LinkedList每个节点只需要存储元素和指针,因此相对较小。

  • 使用场景:ArrayList适用于频繁随机访问和尾部的插入删除操作,而LinkedList适用于频繁的中间插入删除操作和不需要随机访问的场景。

  • 线程安全:这两个集合都不是线程安全的,Vector是线程安全的

2. HashMap hash冲突是怎么解决的?

在 JDK 1.7 版本之前, HashMap 数据结构是数组和链表,HashMap通过哈希算法将元素的键(Key)映射到数组中的槽位(Bucket)。

如果多个键映射到同一个槽位,它们会以链表的形式存储在同一个槽位上,因为链表的查询时间是O(n),所以冲突很严重,一个索引上的链表非常长,效率就很低了。

图片

所以在 JDK 1.8 版本的时候做了优化,当一个链表的长度超过8的时候就转换数据结构,不再使用链表存储,而是使用红黑树,查找时使用红黑树,时间复杂度O(log n),可以提高查询性能,但是在数量较少时,即数量小于6时,会将红黑树转换回链表。

图片

3. HashMap 扩容机制是怎样的?

hashMap默认的负载因子是0.75,即如果hashmap中的元素个数超过了总容量75%,则会触发扩容,扩容分为两个步骤:

  • 第1步是对哈希表长度的扩展(2倍)

  • 第2步是将旧哈希表中的数据放到新的哈希表中。

因为我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。

如我们从16扩展为32时,具体的变化如下所示:

图片

因此元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

图片

因此,我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。可以看看下图为16扩充为32的resize示意图:

图片

这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。

4. HashMap 和 concurrentHashMap的区别是什么?

JDK1.7版本:hashmap和concurrenthashmap的区别

  • 内存结构:hashmap采用数组 + 链表的结构。数组是 HashMap 的主体,链表则是为了解决哈希冲突而存在。当两个不同的键通过哈希函数计算出相同的数组索引时,它们会以链表的形式存储在该索引位置。ConcurrentHashMap采用分段锁机制,内部结构是一个 Segment 数组,每个 Segment 类似于一个小的 HashMap,它也有自己的数组和链表结构。

  • 线程安全性:hashmap是非线程安全的,在多线程环境下,如果多个线程同时对 HashMap 进行读写操作,可能会导致数据不一致、死循环等问题。ConcurrentHashMap是线程安全的。每个 Segment 都有自己的锁,不同的 Segment 可以被不同的线程同时访问,因此在多线程环境下可以提高并发性能,只有当多个线程同时访问同一个 Segment 时,才会发生锁竞争。

  • 性能:hashmap由于没有锁的开销,在单线程环境下性能较好。但在多线程环境下,为了保证线程安全,需要额外的同步机制,这会降低性能。ConcurrentHashMap通过分段锁机制,在多线程环境下可以实现更高的并发性能,不同的线程可以同时访问不同的 Segment,从而减少了锁竞争的可能性。

JDK1.8版本:hashmap和concurrenthashmap的区别

  • 内存结构:hashmap采用数组 + 链表 + 红黑树的结构,当链表长度超过一定阈值(默认为 8)时,链表会转换为红黑树,以提高查找效率。ConcurrentHashMap弃了分段锁机制,采用 CAS + synchronized 来保证线程安全,内部结构同样是数组 + 链表 + 红黑树。

  • 线程安全性:hashmap仍然是非线程安全的,多线程环境下的问题依然存在。ConcurrentHashMap通过 CAS 和 synchronized 保证线程安全。在插入元素时,首先会尝试使用 CAS 操作更新节点,如果 CAS 失败,则使用 synchronized 锁住当前节点,再进行插入操作。

  • 性能:hashmap在单线程环境下,由于红黑树的引入,当链表较长时查找效率会有所提高。ConcurrentHashMap在多线程环境下,由于摒弃了分段锁,减少了锁的粒度,进一步提高了并发性能。同时,红黑树的引入也提高了查找效率。

5. new String("ab")创建了几个对象?

使用 new String("ab") 可能创建 1 个或 2 个对象,具体取决于字符串常量池的状态:

  • 如果字符串常量池中没有 "ab":会先在常量池中创建一个 "ab" 对象。再通过 new 关键字在堆内存中创建一个新的 String 对象(该对象的值与常量池中的 "ab" 相同)。此时总共创建 2 个对象。

  • 如果字符串常量池中已有 "ab":仅通过 new 关键字在堆内存中创建一个新的 String 对象(引用常量池中的 "ab" 作为值)。此时总共创建 1 个对象。

这是因为 new String("ab") 会强制在堆中生成新对象,而双引号直接声明的字符串(如 "ab")会优先使用常量池中的对象以实现复用。

6. StringBuilder和StringBuffer区别,举个使用场景的例子

StringBuilder和StringBuffer的区别主要是在 线程安全性 和 性能 上:

  • **线程安全性:StringBuffer 所有方法都被 synchronized 修饰,是线程安全的,多线程环境下使用不会出现数据不一致问题。而StringBuilder 没有同步锁,线程不安全,但性能更好(省去了锁竞争的开销)。

  • 性能:单线程环境下,StringBuilder 性能优于 StringBuffer(因为避免了同步操作的额外消耗)。

单线程场景(如普通业务逻辑、字符串拼接工具类),适合用 StringBuilder,追求更高性能:

// 单线程下拼接日志信息
public class LogBuilder {
    public static String buildLog(String level, String message) {
        // 单线程环境,用StringBuilder更高效
        StringBuilder sb = new StringBuilder();
        sb.append("[")
          .append(level)
          .append("] ")
          .append(System.currentTimeMillis())
          .append(": ")
          .append(message);
        return sb.toString();
    }
    
    public static void main(String[] args) {
        // 单线程调用,无线程安全问题
        String log = buildLog("INFO", "系统启动完成");
        System.out.println(log); // [INFO] 1620000000000: 系统启动完成
    }
}

多线程场景(如多线程共享的字符串缓冲区),适合用 StringBuffer,保证线程安全:

// 多线程共享的字符串累加器
public class SharedStringAccumulator {
    // 多线程共享资源,用StringBuffer保证线程安全
    private StringBuffer buffer = new StringBuffer();
    
    // 多线程可能同时调用的方法
    public void append(String content) {
        buffer.append(content).append("\\n");
    }
    
    public String getResult() {
        return buffer.toString();
    }
    
    public static void main(String[] args) throws InterruptedException {
        SharedStringAccumulator accumulator = new SharedStringAccumulator();
        
        // 启动多个线程同时追加内容
        Thread t1 = new Thread(() -> accumulator.append("线程1的数据"));
        Thread t2 = new Thread(() -> accumulator.append("线程2的数据"));
        
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        
        System.out.println(accumulator.getResult()); 
        // 输出(顺序可能不同,但内容完整无错乱):
        // 线程1的数据
        // 线程2的数据
    }
}

7. jvm内存区域有哪几块,存放什么东西?

根据 JDK 8 规范,JVM 运行时内存共分为虚拟机栈、堆、元空间、程序计数器、本地方法栈五个部分。还有一部分内存叫直接内存,属于操作系统的本地内存,也是可以直接操作的。

图片

JVM的内存结构主要分为以下几个部分:

  • 程序计数器:可以看作是当前线程所执行的字节码的行号指示器,用于存储当前线程正在执行的 Java 方法的 JVM 指令地址。如果线程执行的是 Native 方法,计数器值为 null。是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域,生命周期与线程相同。

  • Java 虚拟机栈:每个线程都有自己独立的 Java 虚拟机栈,生命周期与线程相同。每个方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。可能会抛出 StackOverflowError 和 OutOfMemoryError 异常。

  • 本地方法栈:与 Java 虚拟机栈类似,主要为虚拟机使用到的 Native 方法服务,在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。本地方法执行时也会创建栈帧,同样可能出现 StackOverflowError 和 OutOfMemoryError 两种错误。

  • Java 堆:是 JVM 中最大的一块内存区域,被所有线程共享,在虚拟机启动时创建,用于存放对象实例。从内存回收角度,堆被划分为新生代和老年代,新生代又分为 Eden 区和两个 Survivor 区(From Survivor 和 To Survivor)。如果在堆中没有内存完成实例分配,并且堆也无法扩展时会抛出 OutOfMemoryError 异常。

  • 方法区(元空间):在 JDK 1.8 及以后的版本中,方法区被元空间取代,使用本地内存。用于存储已被虚拟机加载的类信息、常量、静态变量等数据。虽然方法区被描述为堆的逻辑部分,但有 “非堆” 的别名。方法区可以选择不实现垃圾收集,内存不足时会抛出 OutOfMemoryError 异常。

  • 运行时常量池:是方法区的一部分,用于存放编译期生成的各种字面量和符号引用,具有动态性,运行时也可将新的常量放入池中。当无法申请到足够内存时,会抛出 OutOfMemoryError 异常。

  • 直接内存:不属于 JVM 运行时数据区的一部分,通过 NIO 类引入,是一种堆外内存,可以显著提高 I/O 性能。直接内存的使用受到本机总内存的限制,若分配不当,可能导致 OutOfMemoryError 异常。

8. 垃圾回收算法有哪些?

  • 标记-清除算法:标记-清除算法分为“标记”和“清除”两个阶段,首先通过可达性分析,标记出所有需要回收的对象,然后统一回收所有被标记的对象。标记-清除算法有两个缺陷,一个是效率问题,标记和清除的过程效率都不高,另外一个就是,清除结束后会造成大量的碎片空间。有可能会造成在申请大块内存的时候因为没有足够的连续空间导致再次 GC。

  • 复制算法:为了解决碎片空间的问题,出现了“复制算法”。复制算法的原理是,将内存分成两块,每次申请内存时都使用其中的一块,当内存不够时,将这一块内存中所有存活的复制到另一块上。然后将然后再把已使用的内存整个清理掉。复制算法解决了空间碎片的问题。但是也带来了新的问题。因为每次在申请内存时,都只能使用一半的内存空间。内存利用率严重不足。

  • 标记-整理算法:复制算法在 GC 之后存活对象较少的情况下效率比较高,但如果存活对象比较多时,会执行较多的复制操作,效率就会下降。而老年代的对象在 GC 之后的存活率就比较高,所以就有人提出了“标记-整理算法”。标记-整理算法的“标记”过程与“标记-清除算法”的标记过程一致,但标记之后不会直接清理。而是将所有存活对象都移动到内存的一端。移动结束后直接清理掉剩余部分。

  • 分代回收算法:分代收集是将内存划分成了新生代和老年代。分配的依据是对象的生存周期,或者说经历过的 GC 次数。对象创建时,一般在新生代申请内存,当经历一次 GC 之后如果对还存活,那么对象的年龄 +1。当年龄超过一定值(默认是 15,可以通过参数 -XX:MaxTenuringThreshold 来设定)后,如果对象还存活,那么该对象会进入老年代。

9. Eden区和Survivor区的区别是什么?

Eden 区和 Survivor 区是 JVM 堆内存中年轻代的两个区域,它们的区别如下:

  • 对象分配情况:几乎所有新创建的对象都会首先分配在 Eden 区,它是对象初始分配的主要区域。Survivor 区用于存放从 Eden 区经过垃圾回收后存活下来的对象,而不是直接分配新对象。

  • 垃圾回收触发条件:当 Eden 区被对象填满时,会触发一次 Minor GC,对 Eden 区进行垃圾回收,清理掉不再使用的对象。Survivor 区满了不会直接引发 GC,而是在每次 Minor GC 时,会对 Survivor 区进行处理,将存活的对象复制到另一个 Survivor 区或老年代。

  • 内存管理方式:Eden 区主要采用复制算法进行垃圾回收,将存活对象复制到 Survivor 区,然后清空 Eden 区。Survivor 区的两个部分(From 和 To)在每次 Minor GC 后会互换角色,存活对象从当前的 From Survivor 区复制到 To Survivor 区,然后清空原来的 From Survivor 区,以此来保证内存的连续性,避免内存碎片。

10. 线程的创建方式有几种?

继承Thread类

这是最直接的一种方式,用户自定义类继承java.lang.Thread类,重写其run()方法,run()方法中定义了线程执行的具体任务。创建该类的实例后,通过调用start()方法启动线程。

class MyThread extends Thread {
    @Override
    public void run() {
        // 线程执行的代码
    }
}

public static void main(String[] args) {
    MyThread t = new MyThread();
    t.start();
}

采用继承Thread类方式

  • 优点: 编写简单,如果需要访问当前线程,无需使用Thread.currentThread ()方法,直接使用this,即可获得当前线程

  • 缺点:因为线程类已经继承了Thread类,所以不能再继承其他的父类

实现Runnable接口

如果一个类已经继承了其他类,就不能再继承Thread类,此时可以实现java.lang.Runnable接口。实现Runnable接口需要重写run()方法,然后将此Runnable对象作为参数传递给Thread类的构造器,创建Thread对象后调用其start()方法启动线程。

class MyRunnable implements Runnable {
    @Override
    public void run() {
        // 线程执行的代码
    }
}

public static void main(String[] args) {
    Thread t = new Thread(new MyRunnable());
    t.start();
}

采用实现Runnable接口方式:

  • 优点:线程类只是实现了Runable接口,还可以继承其他的类。在这种方式下,可以多个线程共享同一个目标对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。

  • 缺点:编程稍微复杂,如果需要访问当前线程,必须使用Thread.currentThread()方法。

  • 实现Callable接口与FutureTask

  • java.util.concurrent.Callable接口类似于Runnable,但Callable的call()方法可以有返回值并且可以抛出异常。要执行Callable任务,需将它包装进一个FutureTask,因为Thread类的构造器只接受Runnable参数,而FutureTask实现了Runnable接口。

    class MyCallable implements Callable<Integer> {
        @Override
        public Integer call() throws Exception {
            // 线程执行的代码,这里返回一个整型结果
            return 1;
        }
    }

    public static void main(String[] args) {
        MyCallable task = new MyCallable();
        FutureTask<Integer> futureTask = new FutureTask<>(task);
        Thread t = new Thread(futureTask);
        t.start();

        try {
            Integer result = futureTask.get();  // 获取线程执行结果
            System.out.println("Result: " + result);
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
    }

    采用实现Callable接口方式:

    • 缺点:编程稍微复杂,如果需要访问当前线程,必须调用Thread.currentThread()方法。

    • 优点:线程只是实现Runnable或实现Callable接口,还可以继承其他类。这种方式下,多个线程可以共享一个target对象,非常适合多线程处理同一份资源的情形。

    使用线程池(Executor框架)

    从Java 5开始引入的java.util.concurrent.ExecutorService和相关类提供了线程池的支持,这是一种更高效的线程管理方式,避免了频繁创建和销毁线程的开销。可以通过Executors类的静态方法创建不同类型的线程池。

    class Task implements Runnable {
        @Override
        public void run() {
            // 线程执行的代码
        }
    }

    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(10);  // 创建固定大小的线程池
        for (int i = 0; i < 100; i++) {
            executor.submit(new Task());  // 提交任务到线程池执行
        }
        executor.shutdown();  // 关闭线程池
    }

    采用线程池方式:

    • 缺点:程池增加了程序的复杂度,特别是当涉及线程池参数调整和故障排查时。错误的配置可能导致死锁、资源耗尽等问题,这些问题的诊断和修复可能较为复杂。

    • 优点:线程池可以重用预先创建的线程,避免了线程创建和销毁的开销,显著提高了程序的性能。对于需要快速响应的并发请求,线程池可以迅速提供线程来处理任务,减少等待时间。并且,线程池能够有效控制运行的线程数量,防止因创建过多线程导致的系统资源耗尽(如内存溢出)。通过合理配置线程池大小,可以最大化CPU利用率和系统吞吐量。

    11. 线程池的参数有哪些?

    线程池的构造函数有7个参数:

    图片

    • corePoolSize:线程池核心线程数量。默认情况下,线程池中线程的数量如果 <= corePoolSize,那么即使这些线程处于空闲状态,那也不会被销毁。

    • maximumPoolSize:限制了线程池能创建的最大线程总数(包括核心线程和非核心线程),当 corePoolSize 已满 并且 尝试将新任务加入阻塞队列失败(即队列已满)并且 当前线程数 < maximumPoolSize,就会创建新线程执行此任务,但是当 corePoolSize 满 并且 队列满 并且 线程数已达 maximumPoolSize 并且 又有新任务提交时,就会触发拒绝策略。

    • keepAliveTime:当线程池中线程的数量大于corePoolSize,并且某个线程的空闲时间超过了keepAliveTime,那么这个线程就会被销毁。

    • unit:就是keepAliveTime时间的单位。

    • workQueue:工作队列。当没有空闲的线程执行新任务时,该任务就会被放入工作队列中,等待执行。

    • threadFactory:线程工厂。可以用来给线程取名字等等

    • handler:拒绝策略。当一个新任务交给线程池,如果此时线程池中有空闲的线程,就会直接执行,如果没有空闲的线程,就会将该任务加入到阻塞队列中,如果阻塞队列满了,就会创建一个新线程,从阻塞队列头部取出一个任务来执行,并将新任务加入到阻塞队列末尾。如果当前线程池中线程的数量等于maximumPoolSize,就不会创建新线程,就会去执行拒绝策略。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 招银网络 java后端开发最新面试题
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!