超详细的 Java 基础知识巩固

121 阅读20分钟

一、异常(Exceptions)

1. Java 异常体系结构是怎样的?ErrorException 有什么区别?

Java 异常体系的核心是 Throwable 类,它有两个主要子类:ErrorException

  • Throwable:所有异常和错误的超类。

  • Error:表示程序在运行时发生了不可恢复的严重问题,例如 OutOfMemoryErrorStackOverflowError。这类错误通常不由程序来处理,而是由 JVM 自身或系统问题引起。

  • Exception:表示程序在运行时发生的可恢复问题,通常是由于程序逻辑错误或外部因素(如文件找不到、网络中断)引起的。Exception 又分为:

    • Checked Exception(受检查异常) :在编译时就强制要求处理的异常,例如 IOExceptionSQLException。如果方法中可能抛出这类异常,要么用 try-catch 捕获,要么用 throws 声明。
    • Unchecked Exception(非受检查异常/运行时异常) :在编译时不会强制检查,但在运行时才可能发生的异常,例如 NullPointerExceptionArrayIndexOutOfBoundsException。这类异常通常是由于程序逻辑错误引起的,建议通过改进代码逻辑来避免,而不是频繁捕获。

2. try-catch-finally 中,finally 块什么时候会执行?有什么作用?

finally无论 try 块中是否发生异常,也无论 catch 块是否捕获了异常,都会被执行

作用: finally 块通常用于执行资源清理操作,例如关闭文件流、数据库连接、网络连接等。这能确保即使在异常情况下,关键资源也能被正确释放,避免资源泄露。

特殊情况: 只有在极少数情况下 finally 块不会执行,比如:

  • trycatch 块中执行了 System.exit(0) 强制退出 JVM。
  • trycatch 块中 JVM 崩溃(如 OutOfMemoryError)。

3. throwthrows 有什么区别?

  • throw

    • 用于在方法内部手动抛出一个异常对象。
    • 后面跟的是一个异常实例
    • 语法:throw new MyException("错误信息");
  • throws

    • 用于在方法签名中声明该方法可能抛出的异常类型。
    • 后面跟的是异常类名,多个异常类用逗号分隔。
    • 表示该方法不处理这些异常,而是将它们抛给调用者处理
    • 主要用于声明 Checked Exception
    • 语法:public void readFile() throws IOException, FileNotFoundException { ... }

4. 说说自定义异常的步骤。

  1. 继承 Exception 或其子类(通常是 RuntimeException

    • 如果希望自定义的异常是受检查异常,继承 Exception
    • 如果希望是运行时异常,继承 RuntimeException
  2. 提供构造方法:通常提供一个无参构造方法和带一个字符串参数(表示异常信息)的构造方法。

    Java

    public class MyCustomException extends Exception {
        public MyCustomException() {
            super();
        }
        public MyCustomException(String message) {
            super(message);
        }
        public MyCustomException(String message, Throwable cause) {
            super(message, cause);
        }
        public MyCustomException(Throwable cause) {
            super(cause);
        }
    }
    

二、I/O(Input/Output)

1. Java I/O 流的分类有哪些?各自的特点是什么?

Java I/O 流主要根据数据类型流向进行分类:

按数据类型分:

  • 字节流(Byte Stream)

    • 以字节为单位读写数据,适用于处理任何类型的文件(如图片、视频、文本等)。
    • 基类是 InputStream (输入) 和 OutputStream (输出)。
    • 常见的实现类有 FileInputStream/FileOutputStreamBufferedInputStream/BufferedOutputStream
  • 字符流(Character Stream)

    • 以字符为单位读写数据,专门用于处理文本数据。字符流会自动处理字符编码转换。
    • 基类是 Reader (输入) 和 Writer (输出)。
    • 常见的实现类有 FileReader/FileWriterBufferedReader/BufferedWriterInputStreamReader/OutputStreamWriter

按流向分:

  • 输入流(Input Stream/Reader) :从数据源读取数据到程序。
  • 输出流(Output Stream/Writer) :将程序中的数据写入到数据目的地。

2. 字节流字符流 有什么区别和联系?什么时候用哪个?

区别:

  • 处理单位: 字节流以字节为单位,字符流以字符为单位。
  • 编码: 字符流在读写时会自动进行字符编码和解码(例如,将字节转换为字符,或将字符转换为字节),而字节流不会。
  • 适用范围: 字节流适用于所有文件类型(二进制和文本),字符流只适用于文本文件。

联系:

  • 字符流是基于字节流构建的。例如,InputStreamReader 是将字节输入流转换为字符输入流的桥梁,OutputStreamWriter 是将字符输出流转换为字节输出流的桥梁。

选择:

  • 处理文本文件时,优先使用字符流,因为它能更好地处理字符编码,避免乱码。
  • 处理**非文本文件(二进制文件)**时,必须使用字节流,例如图片、视频、音频等。

3. 什么是缓冲流?使用缓冲流有什么好处?

缓冲流(Buffered Stream) 是在基本 I/O 流之上包装的一层流,它内部维护一个缓冲区(一个字节或字符数组)。

  • 字节缓冲流BufferedInputStreamBufferedOutputStream
  • 字符缓冲流BufferedReaderBufferedWriter

好处:

  • 提高读写效率: 缓冲流会一次性从磁盘读取/写入一块数据到缓冲区,而不是每次读写一个字节/字符,这大大减少了实际的磁盘 I/O 操作次数,从而显著提高读写性能。当缓冲区满或调用 flush() 方法时,数据才会真正写入或读取。
  • 提供额外功能: 例如 BufferedReader 提供了 readLine() 方法,可以按行读取文本,非常方便。

4. Serializable 接口有什么作用?

Serializable 是一个标记接口(不包含任何方法)。当一个类实现了 Serializable 接口时,表示该类的对象可以被序列化(Serialization)

作用:

  • 对象持久化: 将对象转换为字节序列,以便存储到文件或数据库中。
  • 网络传输: 将对象通过网络传输到另一台机器。
  • 进程间通信: 在不同进程之间传递对象。

注意事项:

  • transient 关键字:transient 关键字修饰的字段在对象序列化时会被忽略。
  • static 字段: static 字段属于类,不属于对象实例,因此也不会被序列化。
  • 版本兼容: serialVersionUID 用于控制序列化和反序列化的版本兼容性。

三、集合(Collections)

1. Java 集合框架的顶层接口有哪些?它们之间有什么关系?

Java 集合框架的顶层接口主要有三个:

  • Collection:表示一组不重复的元素(通常无序)。它是所有单列集合的根接口,包括 ListSetQueue
  • List:继承自 Collection,表示有序的集合,元素可以重复。
  • Set:继承自 Collection,表示无序的集合,元素不允许重复
  • Map:表示键值对(key-value)的集合,键不允许重复,值可以重复。Map 接口不继承自 Collection 接口,它是独立存在的。

关系图:

                    Iterable
                       |
                  Collection
                 /      |     \
               List    Set    Queue
                         
                     Map

2. ArrayListLinkedList 有什么区别?

特性ArrayListLinkedList
底层实现基于动态数组基于双向链表
随机访问O(1) ,通过索引直接访问,效率高O(n) ,需要从头或尾遍历查找,效率低
插入/删除O(n) ,需要移动大量元素,效率低O(1) ,只需要修改相邻节点的指针,效率高
内存开销连续内存,但可能需要扩容,涉及数组复制不连续内存,每个节点需要额外的空间存储前后指针
适用场景频繁随机访问元素,但插入/删除操作较少频繁插入/删除元素,但随机访问较少

3. HashMapHashtable 有什么区别?

特性HashMapHashtable
线程安全非线程安全线程安全 (通过 synchronized 实现)
null 键值允许 null 键和 null不允许 null 键和 null
性能性能较高,因为没有同步开销性能较低,因为所有方法都加锁,并发性能差
初始容量/扩容初始容量 16,扩容因子 0.75,扩容为原容量的 2 倍初始容量 11,扩容因子 0.75,扩容为原容量的 2 倍 + 1
父类继承 AbstractMap继承 Dictionary
推荐绝大多数情况下推荐使用 HashMap较少使用,在多线程环境下可使用 ConcurrentHashMap 替代

4. HashMap 的底层实现原理是什么?(Java 8 之后)

HashMap 在 Java 8 之后底层实现是数组 + 链表 + 红黑树

  1. 数组(Node[] tableHashMap 的主干是一个 Node 数组,每个 Node 存储键值对、哈希值和下一个节点的引用。
  2. 链表:当不同的键通过哈希函数计算出相同的索引(哈希冲突)时,这些键值对会以链表的形式存储在该索引位置。
  3. 红黑树:当某个链表中的元素数量超过阈值(默认为 8) ,且数组长度达到一定大小时(默认为 64),链表会转换为红黑树。当红黑树的节点数量低于阈值(默认为 6)时,会重新退化为链表。红黑树能将查找、插入、删除操作的平均时间复杂度从 O(n) 降低到 O(log n) ,提高了极端情况下的性能。

核心流程:

  1. 计算哈希值: key.hashCode() 方法计算哈希值。
  2. 扰动函数: 对哈希值进行扰动(高位运算),减少哈希冲突。
  3. 确定索引: (n - 1) & hash,其中 n 是数组长度,确定元素在数组中的索引位置。
  4. 存储/查找: 如果该位置为空,直接存储。如果存在元素,则遍历链表或红黑树进行比较(equals() 方法)以查找或插入。

5. ConcurrentHashMap 如何保证线程安全?

ConcurrentHashMap 在不同版本有不同的实现策略,但核心思想都是分段锁(Segment)或CAS + synchronized

  • JDK 1.7 及之前:

    • 采用**分段锁(Segment)**机制。ConcurrentHashMap 内部维护一个 Segment 数组,每个 Segment 都是一个独立的 HashEntry 数组,并继承了 ReentrantLock
    • 每次对 ConcurrentHashMap 的操作(如 putremove)会先根据键的哈希值确定要操作哪个 Segment,然后对该 Segment 加锁。
    • 这样,不同 Segment 上的操作可以并发进行,提高了并发性能。只有在扩容时才可能需要全表加锁。
  • JDK 1.8 及之后:

    • 取消了 Segment,而是采用CAS (Compare And Swap) + synchronized 的方式。
    • 在哈希冲突时,如果链表或红黑树的头节点不为 null,则使用 CAS 操作尝试更新,失败则自旋。
    • 对于链表或红黑树中的节点操作,只对当前链表或红黑树的头节点进行 synchronized 加锁。这意味着,不同链表或红黑树的操作可以并发进行,同一链表或红黑树的操作则串行化。
    • 由于锁的粒度更细(从 Segment 级别细化到单个桶的头节点),进一步提高了并发性能。

6. SetList 有什么区别?

特性ListSet
元素顺序有序,元素有索引,可以根据索引访问无序(通常,LinkedHashSet 除外)
元素重复允许重复元素不允许重复元素
常用实现类ArrayListLinkedListVectorHashSetLinkedHashSetTreeSet
典型用途存储需要保持插入顺序或需要按索引访问的元素存储不重复的元素,常用于去重

导出到 Google 表格


四、多线程(Multithreading)

1. 创建线程的几种方式?

主要有三种方式:

  1. 继承 Thread 类并重写 run() 方法

    Java

    class MyThread extends Thread {
        @Override
        public void run() {
            // 线程执行的任务
            System.out.println("Thread extending Thread is running.");
        }
    }
    // 使用:new MyThread().start();
    

    缺点: Java 是单继承的,继承 Thread 后就不能再继承其他类了。

  2. 实现 Runnable 接口并实现 run() 方法

    Java

    class MyRunnable implements Runnable {
        @Override
        public void run() {
            // 线程执行的任务
            System.out.println("Thread implementing Runnable is running.");
        }
    }
    // 使用:new Thread(new MyRunnable()).start();
    

    优点: 避免了单继承的限制,推荐使用。

  3. 实现 Callable 接口并实现 call() 方法(配合 FutureTask 或线程池)

    Java

    import java.util.concurrent.*;
    
    class MyCallable implements Callable<String> {
        @Override
        public String call() throws Exception {
            // 线程执行的任务,可以有返回值,可以抛出异常
            Thread.sleep(1000);
            return "Callable task finished!";
        }
    }
    // 使用:
    // ExecutorService executor = Executors.newFixedThreadPool(1);
    // Future<String> future = executor.submit(new MyCallable());
    // String result = future.get(); // 获取返回值
    // executor.shutdown();
    

    优点: call() 方法可以有返回值,并且可以抛出异常。

2. Thread 类中的 start()run() 方法有什么区别?

  • start() 方法:

    • 用于启动一个新线程,让 JVM 调用该线程的 run() 方法。
    • 当调用 start() 方法时,操作系统会为该线程分配资源,并将其放入线程调度器中,等待 CPU 调度执行。
    • 每个线程只能调用一次 start() 方法。
  • run() 方法:

    • 包含线程真正执行的任务逻辑
    • 直接调用 run() 方法不会启动新线程,而是在当前线程中同步执行 run() 方法的代码,就像调用一个普通方法一样。

总结: 调用 start() 才是多线程的体现,它会创建一个新的线程并使其进入就绪状态;直接调用 run() 只是单线程的普通方法调用。

3. 线程的生命周期有哪些状态?

线程的生命周期主要有以下六种状态:

  1. NEW(新建) :线程被创建但尚未启动。

  2. RUNNABLE(可运行) :线程已调用 start() 方法,正在等待 JVM 调度执行,或正在执行。它包括:

    • READY(就绪) :等待 CPU 调度。
    • RUNNING(运行中) :正在执行 run() 方法中的代码。
  3. BLOCKED(阻塞) :线程正在等待获取一个监视器锁(例如,进入 synchronized 代码块/方法),而该锁被其他线程持有。

  4. WAITING(等待) :线程无限期地等待另一个线程执行特定操作(例如,调用 Object.wait()Thread.join()LockSupport.park())。

  5. TIMED_WAITING(有时限等待) :线程在指定的时间内等待另一个线程执行特定操作(例如,调用 Thread.sleep(long millis)Object.wait(long timeout)Thread.join(long millis)LockSupport.parkNanos()LockSupport.parkUntil())。

  6. TERMINATED(终止) :线程的 run() 方法执行完毕,或者因异常退出。

4. synchronized 关键字的作用?它能作用在哪些地方?

synchronized 关键字是 Java 中用于实现同步的机制,它能保证同一时刻只有一个线程访问被同步的代码块或方法,从而防止多线程环境下数据的不一致性。

作用:

  • 原子性: 保证操作的原子性,即一个操作要么全部执行,要么全部不执行,不会被中断。
  • 可见性: 保证对共享变量的修改对其他线程立即可见。
  • 有序性: 保证被同步代码的执行顺序。

作用位置:

  1. 同步实例方法: 锁住当前对象的实例(this)。

    Java

    public synchronized void method() {
        // 同步代码
    }
    
  2. 同步静态方法: 锁住当前类的 Class 对象。

    Java

    public static synchronized void staticMethod() {
        // 同步代码
    }
    
  3. 同步代码块:

    • 同步实例对象: 锁住指定对象实例。

      Java

      public void method() {
          synchronized (this) { // 锁住当前对象实例
              // 同步代码
          }
      }
      

      或锁住其他对象:synchronized (otherObject)

    • 同步 Class 对象: 锁住指定类的 Class 对象,效果等同于同步静态方法。

      Java

      public void method() {
          synchronized (MyClass.class) { // 锁住 MyClass 类
              // 同步代码
          }
      }
      

5. volatile 关键字的作用?它能保证原子性吗?

volatile 关键字主要用于修饰共享变量,它能保证:

  1. 可见性(Visibility) :当一个线程修改了 volatile 变量的值,新值会立即被刷新到主内存,并且其他线程在读取该变量时会强制从主内存中获取最新值,而不是使用自己的工作内存副本。
  2. 有序性(Ordering) :通过插入内存屏障,防止指令重排序,保证 volatile 变量读写操作的有序性。

volatile 能保证原子性吗?

不能! volatile 不能保证原子性

原子性操作是指一个操作不可中断,要么全部执行成功,要么全部不执行。volatile 只能保证单个读写操作的原子性(例如 i = 10;),但对于复合操作(如 i++;,它包含读取、修改、写入三个步骤)则无法保证原子性。在并发环境下,i++ 仍然可能出现问题。要保证复合操作的原子性,需要使用 synchronizedjava.util.concurrent.atomic 包中的原子类(如 AtomicInteger)。

6. wait()notify()notifyAll() 方法有什么区别?它们为什么必须和 synchronized 关键字一起使用?

这些方法都定义在 Object 类中,用于线程间的协作。

  • wait()

    • 使当前线程释放持有的锁,并进入 WAITINGTIMED_WAITING 状态。
    • 线程会一直等待,直到被其他线程调用 notify()notifyAll() 唤醒,或者达到指定的超时时间(带参数的 wait())。
    • 被唤醒后,线程需要重新竞争锁才能继续执行。
  • notify()

    • 唤醒一个在当前对象上等待的线程。具体是哪个线程被唤醒,取决于 JVM 的调度,不确定。
    • 被唤醒的线程不会立即执行,它仍需等待当前线程释放锁后,才能去竞争锁。
  • notifyAll()

    • 唤醒所有在当前对象上等待的线程。
    • 所有被唤醒的线程都会进入锁的竞争队列,只有一个线程能获得锁并继续执行。

为什么必须和 synchronized 关键字一起使用?

  1. 避免 IllegalMonitorStateException wait()notify()notifyAll() 方法的调用者必须是当前线程所持有的锁对象。如果不在 synchronized 代码块/方法中使用它们,会导致 IllegalMonitorStateException
  2. 保证线程安全和状态一致性: 这些方法是线程间通信的手段,它们依赖于对共享资源的同步访问。synchronized 确保了在调用这些方法时,线程能拥有对象的锁,从而保证了共享数据的可见性和一致性,避免了竞态条件和死锁。线程在等待时释放锁,在被唤醒时重新竞争锁,也正是通过 synchronized 实现的互斥机制。

7. 说说 ThreadLocal 的作用和原理?

作用:

ThreadLocal 提供了一种线程局部变量的机制。这意味着,每个线程都拥有其自身独立的一个变量副本,互不影响。它解决了在多线程环境下,为每个线程保存其独立状态的问题,避免了线程安全问题,也无需进行同步。

常见应用场景:

  • 数据库连接: 每个线程持有自己的数据库连接,避免连接共享和竞争。
  • 用户会话信息: 在 Web 应用中,每个请求线程可以保存当前用户的会话信息。
  • 线程上下文: 传递一些与线程相关的上下文信息。

原理:

ThreadLocal 的原理是,在每个 Thread 对象内部维护一个 ThreadLocalMap(一个弱引用 WeakReferenceEntry 数组)。

  1. 当调用 ThreadLocal.set(value) 方法时,ThreadLocal 会获取当前线程,并以当前 ThreadLocal 实例作为 ThreadLocalMap 的键,value 作为值存入该线程的 ThreadLocalMap 中。
  2. 当调用 ThreadLocal.get() 方法时,ThreadLocal 会获取当前线程,然后从该线程的 ThreadLocalMap 中以自身为键获取对应的值。

内存泄漏问题:

ThreadLocalMap 中的键是 ThreadLocal 实例的弱引用,而值是对实际对象的强引用。

  • 如果 ThreadLocal 实例不再被外部引用,它会被 GC 回收。但此时 ThreadLocalMap 中对应的 Entry 的键会变为 null
  • 如果不对该 Entry 进行清理,它的值(实际对象)仍然被 ThreadLocalMap 强引用着,导致该值无法被 GC 回收,从而引发内存泄漏

最佳实践: 为了避免内存泄漏,在使用完 ThreadLocal 后,务必调用 ThreadLocal.remove() 方法来显式地移除当前线程中对应的变量副本。

8. 什么是线程池?使用线程池有什么好处?

线程池(Thread Pool) 是一种管理和复用线程的机制。它在应用启动时预先创建一定数量的线程,这些线程处于等待状态。当有任务到来时,线程池会分配一个空闲线程来执行任务;任务执行完毕后,线程不会被销毁,而是返回线程池中等待下一个任务。

使用线程池的好处:

  1. 降低资源消耗: 避免了线程的频繁创建和销毁带来的性能开销。
  2. 提高响应速度: 任务到达时,无需等待线程创建,可立即执行。
  3. 提高线程的可管理性: 统一管理线程,方便进行监控、调优和限制线程数量,避免因创建过多线程导致系统资源耗尽。
  4. 提供更多功能: 线程池提供了定时执行、周期性执行等高级功能。

9. 线程池的核心参数有哪些?

使用 ThreadPoolExecutor 创建线程池时,主要有以下几个核心参数:

  1. corePoolSize (核心线程数)

    • 线程池中一直保持活动状态的线程数量,即使它们处于空闲状态。
    • 只有当工作队列已满时,才会创建新线程超过这个数量。
  2. maximumPoolSize (最大线程数)

    • 线程池允许创建的最大线程数量。
    • 当工作队列已满,且当前线程数小于 maximumPoolSize 时,线程池会创建新线程来处理任务。
  3. keepAliveTime (空闲线程存活时间)

    • 当线程池中的线程数量超过 corePoolSize 时,这些多余的空闲线程在终止之前等待新任务的最长时间。
    • 默认单位是毫秒,可以通过 TimeUnit 指定其他单位。
  4. unit (时间单位)

    • keepAliveTime 参数的时间单位,例如 TimeUnit.SECONDSTimeUnit.MILLISECONDS
  5. workQueue (工作队列/任务队列)

    • 用于存放等待执行的任务的阻塞队列。

    • 常用的队列类型:

      • ArrayBlockingQueue:基于数组的有界阻塞队列。
      • LinkedBlockingQueue:基于链表的有界(默认无界)阻塞队列。
      • SynchronousQueue:不存储元素的阻塞队列,每个插入操作必须等待一个对应的移除操作。
      • PriorityBlockingQueue:支持优先级的无界阻塞队列。
  6. threadFactory (线程工厂)

    • 用于创建新线程的工厂,可以自定义线程的命名、优先级、是否为守护线程等。
  7. RejectedExecutionHandler (拒绝策略)

    • 当工作队列已满且线程池中的线程数达到 maximumPoolSize 时,新提交的任务将触发拒绝策略。

    • 常用的策略有:

      • AbortPolicy (默认):直接抛出 RejectedExecutionException
      • CallerRunsPolicy:由提交任务的线程(调用者线程)自己来执行任务。
      • DiscardOldestPolicy:丢弃队列中等待时间最长的任务,然后尝试提交当前任务。
      • DiscardPolicy:直接丢弃当前任务,不抛异常。

10. 乐观锁悲观锁 有什么区别?各有什么优缺点和使用场景?

这是并发控制的两种思想。

  • 悲观锁(Pessimistic Lock)

    • 概念: 顾名思义,对数据修改持悲观态度,认为数据每次修改都会发生冲突,所以在每次操作数据之前,都会先获取锁,确保同一时刻只有一个线程能操作数据。
    • 实现: Java 中通过 synchronized 关键字和 ReentrantLock 实现。数据库中通过 SELECT ... FOR UPDATE 实现行级锁。
    • 优点: 简单粗暴,能有效保证数据一致性。
    • 缺点: 性能开销大,容易产生死锁,如果并发度不高,会降低效率。
    • 使用场景: 适用于写多读少的场景,或并发冲突严重的场景,确保数据强一致性。
  • 乐观锁(Optimistic Lock)

    • 概念: 对数据修改持乐观态度,认为数据冲突的概率较低,所以在操作数据时,不会先加锁,而是通过某种机制(如版本号或 CAS 算法)在更新时判断数据是否被其他线程修改过。如果被修改过,则操作失败,需要重试。

    • 实现:

      • 版本号(Version)机制: 在数据表中增加一个 version 字段,每次更新数据时,都会检查当前 version 是否与数据库中的 version 一致,一致则更新并 version+1,不一致则表示数据已被修改,更新失败。
      • CAS (Compare And Swap) 算法: Java 中 Atomic 包就是基于 CAS 实现的。它包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。如果内存位置 V 的值与预期原值 A 相匹配,那么处理器会自动将该位置更新为新值 B;否则,处理器不做任何操作。无论哪种情况,它都会返回该位置的旧值。这个操作是原子性的。
    • 优点: 性能开销小,不会死锁,适用于读多写少的场景。

    • 缺点: 如果冲突频繁,会导致大量重试,反而降低性能。需要额外的代码逻辑来处理重试。

    • 使用场景: 适用于读多写少的场景,并发冲突较少,追求高并发性能。