java常见面试题总结

156 阅读23分钟

Java基础知识点

介绍一下Java中的基本数据类型。

Java中的基本数据类型可以分为四类:整数型、浮点型、字符型和布尔型。

整数型包括了:

  1. byte:占用1个字节,范围是-128到127。
  2. short:占用2个字节,范围大约是-32k到32k。
  3. int:占用4个字节,范围大约是-21亿到21亿,这也是我们在编程中最常用的整数类型。
  4. long:占用8个字节,当int的范围不够使用时,我们会使用long类型。

浮点类型包括了:

  1. float:单精度浮点类型,占用4个字节。
  2. double:双精度浮点类型,占用8个字节,精度比float更高。

字符型包括了一个类型:

  1. char:用于表示单一的字符,如'a'、'b'等。

布尔型包括了一个类型:

  1. boolean:用于表示真或假,值只有两个,即true和false。

这些基本数据类型在Java中都有对应的包装类,如Integer对应int,Double对应double,使得基本类型能够在需要使用对象的地方使用。

解释一下Java中的错误和异常的区别。

在Java中,错误(Error)和异常(Exception)都是继承自java.lang.Throwable 类。它们都可以用于表示程序中出现的问题,但在含义和用途上有所不同。

  1. 错误(Error):错误通常是由Java运行时环境系统内部错误或资源耗尽导致的严重问题,如:OutOfMemoryError、NoClassDefFoundError、StackOverflowError等。这类问题一般是比较严重的,通常与系统环境、资源配置、运行状态等因素有关,通常不由程序本身来处理。

  2. 异常(Exception):异常是程序正常运行中,为了处理某些问题而被抛出的事件。一个合理的应用程序应该设法去处理那些可能会发生的异常。在Java中,异常被分为两大类:受检异常(checked exception)和非受检异常(unchecked exception)。受检异常在编译时就需要被处理(例如IOException),否则编译器会报错;非受检异常则可以选择处理或不处理(例如NullPointerException)。

总结来说,错误通常由系统环境问题引起,程序通常无法处理,而异常则常常是由程序自身错误引起,程序应当尽可能处理这些异常,保证程序的稳定运行。

什么是泛型?Java的泛型是如何工作的?

泛型的概念首次在Java 5中引入,它是一种在编码时使用的代表类型的参数化工具。使用 Java 泛型,可以使得类,接口和方法更加通用,因为他们可以接受任意数据类型。

例如,假设有一个 List 类型的数组,如果没有泛型,您可能需要为 Integer、String 和其他类型创建 List,这样就造成很大的代码冗余。但是有了泛型,在定义这个 List 的时候,你就可以指定一个类型参数,例如 List ,然后在使用的时候,像 List 或者 List 这样指定具体的类型,这样的代码更为简洁,类型检查更为完善。

Java 的泛型其实是一种编译时工具,这就意味着泛型的信息在编译时会被用来进行严格类型检查,而且所有的泛型表达式会被移除,并替换为原有的原生类型或者相关的类型转换,这种机制被称为类型擦除。例如, List 和 List 在运行时的类型实际上都是List。

所以,Java 泛型在运行时并不存在,它只是帮助程序员在编译时确保程序更严密和稳定,并且避免了一些不必要的类型转换。

Java的垃圾回收机制是如何工作的?

Java的垃圾回收(Garbage Collection,GC)机制是Java内存管理的核心部分,它能够自动回收没有任何引用的对象所占用的内存,以防止内存泄漏。Java的GC主要涉及到四部分:对象的分配、GC Roots的可达性分析、垃圾回收与内存分配。

  1. 对象分配:在Java中,所有的对象都在堆内存中创建。有两个指针,一个是start指针,一个是end指针。当创建新的对象时,Java会从start指针开始分配内存,如果可用空间足够,就会创建对象,反之抛出OutOfMemoryError错误。

  2. GC Roots的可达性分析:GC Roots包括以下对象:当前正在执行的方法中的局部变量、活动线程、所有类的静态字段以及JNI引用。Java的GC通过从GC Roots开始,进行可达性分析,找出所有可以访问到的对象,剩下的未被访问到的对象就被判断为没有引用,可以被GC回收。

  3. 垃圾回收与内存分配:Java的垃圾回收器(Garbage Collector) 主要使用标记-清除-整理算法,对堆中的对象进行回收。在标记阶段,垃圾回收器会标记出所有需要被回收的对象;在清除阶段,垃圾回收器会释放掉被标记对象所占用的内存;在整理阶段,垃圾回收器会将剩余的对象向内存区域的一端移动,使得剩余的内存位于内存区的另一端,这样就有了连续的内存空间可以分配给新的对象。

  4. 内存分配:Java使用分代收集策略进行内存分配。Java堆被划分为新生代和老年代。新创建的对象首先被分配到新生代,经过多次GC后仍然存活的对象会被放进老年代。新生代的GC(Minor GC)频率较高,但每次回收时间短。老年代的GC(Major GC或Full GC)频率低,但每次回收时间长。

以上是Java垃圾回收机制的大致工作流程,但不同的垃圾回收器可能有不同的具体实现和优化策略。例如,一些垃圾回收器可能使用并行或并发的策略来提高垃圾回收的性能。

###介绍一下Java中的集合类和它们的使用场景。 Java 的集合类主要包括以下几种:

  1. List:List 接口是有序集合,可以有重复的元素。List 允许多个 null 元素。常用的 List 实现类有 ArrayList 和 LinkedList。ArrayList 是基于动态数组实现,适合随机查找和遍历,而插入和删除则相对低效。LinkedList 基于双向链表实现,适合在列表开头、结尾、中间插入和删除操作。

  2. Set:Set 接口是无序的,不可重复的集合,所以 Set 集合没有带索引的方法。常用的 Set 实现类有 HashSet、LinkedHashSet 和 TreeSet。HashSet 保证元素唯一性的机制是依赖于 HashMap,它不保证元素的顺序。LinkedHashSet 继承于 HashSet,但是使用链表维护了元素的插入顺序。TreeSet 是一个有序的集合,它的作用是提供有序的 Set 集合。

  3. Map:Map 接口是键值对的集合接口。常用的 Map 实现类有 HashMap、LinkedHashMap 和 TreeMap。HashMap 是最常用的Map,它根据键的 hashCode 值存储数据,大多数情况下可以直接定位到它的值,因此它的效率最高。LinkedHashMap 保持了元素插入的顺序。TreeMap 实现 SortedMap 接口,能够把它保存的记录根据键排序。

不同类型的集合类适用于不同的场景,我们在实际编程中需根据需要选择合适的集合类来使用。

Java框架

如何在Spring框架中实现依赖注入?

在Spring框架中,依赖注入是一种主要的组件组装方式。它主要是通过以下两种方式实现的:Setter注入和构造器注入。

  1. Setter注入:这种方式是通过调用Bean的setter方法实现注入。在配置文件中使用标签指明依赖对象。例如:
<bean id="exampleBean" class="examples.ExampleBean">
    <property name="beanOne" ref="anotherExampleBean"/>
    <property name="beanTwo" ref="yetAnotherBean"/>
    <property name="integerProperty" value="1"/>
</bean>

在上述例子中,beanOne、beanTwo就是ExampleBean依赖注入的Bean。

  1. 构造器注入:这种方式是通过调用Bean的构造方法实现的,在配置文件中使用标签指明依赖对象。例如:
<bean id="exampleBean" class="examples.ExampleBean">
    <constructor-arg ref="anotherExampleBean"/>
    <constructor-arg ref="yetAnotherBean"/>
    <constructor-arg type="int" value="1"/>
</bean>

在这个例子中,anotherExampleBean, yetAnotherBean 就是通过构造方法注入的依赖。

此外,Spring 2.5 添加了通过注解的方式进行依赖注入的支持,常用的注解有@Autowired、@Resource等。

  • @Autowired:Spring提供的注解,可以对类成员变量、方法及构造函数进行标注,完成自动装配的工作。

  • @Resource:J2EE提供的注解,默认按照ByName进行装配。

这些都是Spring依赖注入的方式,可以根据实际需求选择合适的方式。

你如何理解MyBatis的一级和二级缓存?

MyBatis 主要提供了一级缓存和二级缓存两种缓存机制。

  1. 一级缓存:一级缓存是 SqlSession 级别的缓存,在我们执行查询时,会先看当前 SqlSession 的一级缓存中是否有数据,如果有数据就会直接从缓存中拿,不执行数据库操作。当会话提交或者关闭以后,会清除一级缓存。一级缓存使得在一个 SqlSession 中执行相同的 SQL 语句可以避免再次与数据库交互,从而提高程序性能。

  2. 二级缓存:二级缓存是 mapper 级别的缓存,一个mapper的所有 SqlSession 共享二级缓存。可以被多个SqlSession共享。二级缓存可以实现数据的共享,减少数据库交互次数,从而提高应用性能。但使用二级缓存的时候,我们要特别注意其线程安全问题。

以下是对于一级缓存和二级缓存的一些理解:

  • 一级缓存是基于 Per SqlSession 级别的,而二级缓存是 Per Mapper 级别的。
  • 一级缓存的主要目的是为了减少同一个 SqlSession 对数据库的查询次数,而二级缓存主要是为了减少不同 SqlSession 对数据库的查询。
  • 一级缓存无法关闭,二级缓存可以通过配置关闭或者开启。
  • 一级缓存不具备跨线程共享数据的能力,而二级缓存可以实现数据的跨线程共享。

在使用缓存时,还需要注意缓存穿透,缓存击穿等高并发问题,并合理设置缓存的大小,防止出现 OOM。此外,使用缓存时需要考虑缓存与数据库的一致性问题,一般可通过设置合适的失效策略,或者在修改数据库时同步更新缓存来保持一致性。

SpringBoot与SpringMVC的区别是什么?

Spring Boot和Spring MVC都是Spring生态中的项目,各有其特性和所解决的问题,它们并非竞争关系,而是相辅相成的。

  1. Spring MVC

    Spring MVC是Spring Framework的一部分,用于简化基于服务器的应用程序(例如web应用程序)的开发和测试。它是一个轻量级且适应性强的MVC(Model-View-Controller)框架。通过Spring MVC,开发者能够更加轻松地设计出清晰分层的Web应用程序。Spring MVC支持RESTful URL的映射以及数据的数据转换,提供了丰富的数据验证和绑定支持。

  2. Spring Boot

    Spring Boot是基于Spring的一个框架,通过简化配置和提供一系列开箱即用的框架默认设置,使得Spring的配置更加简单,开发者更容易使用Spring应用和服务。Spring Boot可以自动配置Spring和第三方库,降低了项目搭建的复杂性。Spring Boot也支持创建可“独立”运行的Spring应用,可以打包成jar并通过java -jar直接运行。

就两者的区别而言:

  • Spring MVC重点是MVC框架的实现,主要处理web层的开发工作;而Spring Boot是简化Spring应用开发的所有层面,包括数据访问层、业务层以及web层。
  • Spring MVC需要开发者自定义大量配置,如数据库连接、MVC组件定位等;而Spring Boot则内置了这些配置,约定优于配置,开箱即用。
  • Spring Boot内嵌了Servlet容器(如Tomcat),无需部署至web服务器就可以直接启动web应用;而Spring MVC没有内嵌的Servlet容器,需要将war包部署至web服务器。
  • Spring Boot提供了一个Spring Boot Actuator,开发者可以轻松地构建生产级的应用并监控应用的各项指标,Spring MVC中没有类似的工具。

所以,并不是选择Spring Boot就一定舍弃Spring MVC,开发者可以在Spring Boot的基础上使用Spring MVC,从而结合两者的优点。

Hibernate的懒加载是什么?

Hibernate的懒加载是一种性能优化机制,也就是说只有当你真正使用到数据的时候才真正读取数据。

给你一个具体的例子来解释。假设我们有一个订单(Order)对象,每个订单对象都有一个客户(Customer)对象引用。现在,如果你要加载一个订单对象,那么你可能只需要订单的信息,而不需要获取与之关联的客户的信息。在这种情况下,Hibernate就可以实行懒加载——当你加载订单对象的时候,仅仅加载订单的信息,而不加载客户的信息,当你真正要使用客户信息的时候才去加载。

什么情况适合使用懒加载?那就是当你考虑数据加载时造成的性能开销和你实际业务使用到的可能性,如果一些数据只有少数情况下被使用到,那么就应该考虑使用懒加载。

除了在一对一、一对多的情况适用懒加载,集合对象也可以运用到懒加载。当你需要访问集合中的元素时,Hibernate会按照你规定的数量去加载对象,一次并不能加载全部数据,只有当你在访问下一个元素时,Hibernate 才会继续从数据库中加载。

这种按需加载的方式能让Hibernate工作得更高效,并且避免不必要的性能损失。

  1. 设计模式

    解释一下你熟悉的设计模式,并给出使用场景。

    我可以向你介绍一些常见的设计模式并给出具体的使用场景。

    1. 单例模式(Singleton Pattern):这种模式保证一个类仅有一个实例,并提供一个全局访问点。比如,在应用中对于数据库的连接配置,我们通常只需要一个全局的配置,这种状态下,就可以使用单例模式。

    2. 工厂模式(Factory Pattern):这种模式提供了一种创建对象的接口,将对象的实例化推迟到子类进行。例如,在实际文件的读写操作中,你可能需要处理各种文件格式,如CSV,XML,或者XLS,此时你可以使用工厂模式创建一个工厂类,这个类会根据传入的文件类型参数返回一个合适的处理类的实例。

    3. 观察者模式(Observer Pattern):当一个对象的状态发生变化时,它的依赖对象将得到通知并被更新。例如,实时消息推送就是一个典型的场景,一旦有新的消息产生,系统就可以自动通知该消息的订阅者。

    4. 策略模式(Strategy Pattern):策略模式定义了一系列的算法,并将每个算法封装起来,使得它们可以相互替换,让算法独立于使用它的客户端。例如,如果有多种排序算法(比如冒泡排序、快速排序等),可以用策略模式将这些排序方法封装起来,根据需要选择使用哪一种。

    需要注意的是,设计模式并不是越多用越好,需要根据实际的系统需求和设计选择适合的模式。设计模式的目的是重用经验,提高代码的可复用性、可维护性和可扩展性。

    如何使用单例模式?解释一下单例设计模式的实现要点。

    单例模式(Singleton Pattern)是一种常用的软件设计模式,主要目标是确保一个类只有一个实例,并提供一个全局访问点。单例模式的实现一般需要满足以下要点:

    • 单一实例:单例类只能有一个实例。这是通过私有化类的构造方法实现的,防止其他对象使用 new 来创建这个类的实例。
    • 全局访问:单例类被全局的其他对象所识别和访问。通常是通过在单例类中创建静态方法来获取单例对象实例。

    以下是一个简单的Java单例设计模式实现例子:

    public class Singleton {
        // 在静态内部类中持有单例类的实例,并且可以直接被初始化
        private static class Holder {
            private static Singleton instance = new Singleton();
        }
        
        // 私有化构造方法
        private Singleton() {}
    
        // 提供公开的静态方法,返回单例对象实例
        public static Singleton getInstance() {
            return Holder.instance;
        }
    }
    

    这种被称为“静态内部类”的实现方法既保证了线程安全,又避免了同步带来的性能影响。同时,由于实例的创建是在类加载的时候完成,所以能避免多线程同步问题。

    有一点需要注意的是,如果单例类被反射或反序列化方式调用,可能会创建新的实例。对于这种情况,需要在单例类中添加防止反射和反序列化创建新实例的代码。

    什么是工厂模式?解释一下使用工厂模式的好处。

    工厂模式是一种创建型设计模式,主要的思想是定义一个用于创建对象的接口,让子类决定实例化哪一个类,使得一个类的实例化延迟到其子类。

    工厂模式主要有三种类型:简单工厂模式(Simple Factory)、工厂方法模式(Factory Method)和抽象工厂模式(Abstract Factory)。

    简单工厂模式:有一个工厂类负责创建的对象。客户端只需要调用工厂类的静态方法就可以创建对象,无需知道具体的创建逻辑。

    工厂方法模式:定义一个用于创建对象的接口,让子类决定实例化哪一个类。这使得一个类的实例化延迟到其子类。

    抽象工厂模式:提供一个创建一系列相关或相互依赖对象的接口,而无需指定他们具体的类。

    使用工厂模式的好处包括:

    1. 降低了代码的耦合度:客户端无需知道具体的产品类,只需要知道对应的工厂即可。产品可以独立于客户端进行变化。

    2. 提高了代码的可扩展性:如果需要增加新的产品,只需扩展工厂类或者新增工厂类,客户端无需进行修改。

    3. 提高了代码的封装性:隐藏了对象创建的细节,客户端只需关注如何获取对象。

    4. 提高了软件系统的可维护性:由于工厂类承担了对象的创建角色,导致系统中的业务对象更专注于业务的实现,系统结构更清晰,所以更易维护。

  2. 数据结构和算法

    解释一下二分查找算法,并写出实现代码。

    二分查找法是一种在有序数组中查找某一特定元素的搜索算法。搜索过程从中间元素开始,如果中间元素正好是要查找的元素,则查找过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半区域查找,而且用同样的查找过程。这样每一次比较就能排除一半的数据,查找效率比较高。

    以下是一个简单的二分查找的Java实现:

    public class BinarySearch {
        public static int binarySearch(int[] array, int target) {
            int low = 0;
            int high = array.length - 1;
            while (low <= high) {
                int mid = low + (high - low) / 2;
                if (array[mid] == target) {
                    return mid; // 目标元素在中间索引位置
                } else if (array[mid] < target) {
                    low = mid + 1; // 目标元素在右侧,更新low
                } else {
                    high = mid - 1; // 目标元素在左侧,更新high
                }
            }
            return -1; // 没找到返回-1
        }
    
        public static void main(String[] args) {
            int[] array = {1, 3, 5, 7, 9, 11};
            int target = 7;
            int result = binarySearch(array, target);
            System.out.println(result != -1 ? "Element found at index: " + result : "Element not found");
        }
    }
    

    这段代码中的 binarySearch(array, target) 函数会返回目标元素在数组中的位置,如果数组中没有该元素,则返回-1。每次通过更新 low 或 high 来缩小搜索范围,从而实现查找过程的二分。

    主要需要注意的是,在计算 mid 索引时,使用了 low + (high - low) / 2,而非 (low + high) / 2。这是为了防止在计算中间索引时发生 integer overflow(整数溢出)。

    用Java实现快速排序算法。

    快速排序是一种常用的排序算法,由C.A.R. Hoare在1960年提出。它的基本思想是,通过一轮的元素交换,将待排序的数组划分为两个部分,其中一部分的所有数据都比另一部分的所有数据要小。然后,再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。

    以下是一个简单的快速排序的Java实现:

    public class QuickSort {
        public void sort(int[] arr, int low, int high) {
            if (low < high) {
                int pivot = partition(arr, low, high);
                sort(arr, low, pivot - 1);
                sort(arr, pivot + 1, high);
            }
        }
    
        private int partition(int[] arr, int low, int high) {
            int pivot = arr[low];
            while (low < high) {
                while (low < high && arr[high] >= pivot) {
                    high--;
                }
                arr[low] = arr[high];
                while (low < high && arr[low] <= pivot) {
                    low++;
                }
                arr[high] = arr[low];
            }
            arr[low] = pivot;
            return low;
        }
    }
    

    在此代码中,sort方法是主要的排序方法,采取递归的方式进行。partition方法用于实现一轮的元素交换,将小于基准的元素和大于基准的元素放到数组的两侧,返回基准的位置。

5. 系统设计

如何设计一个高并发系统?

设计一个高并发系统不仅需要技术的积累,更需要全局的视角和深入理解业务。以下是一些常见的处理高并发的策略:

  1. 垂直扩展:提高单机处理能力。例如,升级服务器硬件、优化数据库设计、优化查询语句、增加缓存等。

  2. 水平扩展:通过增加机器数量来提高系统的整体处理能力。例如,使用负载均衡将请求分发到多台服务器,使用集群来增强数据库的处理能力。

  3. 数据分离:将应用的读和写操作分离,比如,通过主从复制的方式,将读操作在一组服务器上执行,而将写操作在另一组服务器上执行。

  4. 服务化:将复杂的业务流程拆分为多个独立、互相调用的服务,可以降低系统的复杂度,提高系统的伸缩性。

  5. 缓存:缓存有很多种形式,如浏览器缓存、CDN缓存、页面缓存、接口缓存、数据缓存等。缓存能有效减少数据库的访问量,提高数据读取速度。

  6. 异步处理和队列:采用异步处理的方式,可以快速响应用户请求;使用队列,可以将需要长时间处理的任务排队,后台逐个处理,保证每个任务都能得到处理但不影响用户感知。

  7. 数据库的读写分离和分库分表:对于关系型数据库,可以使用读写分离和分库分表的方式,降低数据库访问的压力。

从以上措施我们可以看到,设计高并发系统是一个需要系统的角度考虑的问题,需要结合软硬件、数据库、网络、业务等方面进行综合设计。

介绍一下分布式事务,如何处理分布式事务?

分布式事务是指在一个分布式系统中的多个节点上涉及多个资源对象事务的执行。它需要保证业务的原子性,即所有的操作要么全部成功,要么全部失败,不能出现部分节点操作成功,部分节点操作失败的情况。

分布式事务的处理相较于单机事务,更加复杂和困难,主要面临的问题有网络问题、数据一致性问题等。

下面是常见的几种处理分布式事务的解决方案:

  1. 两阶段提交(2PC): 它的核心是引入了协调者角色用于统一调度参与者的行为。第一阶段,协调者询问参与者是否准备提交事务,参与者回复Yes或No;第二阶段,如果协调者收到所有参与者的回应都是Yes,那么协调者发送提交事务的请求,否则发送中止事务的请求。

  2. 三阶段提交(3PC): 在两阶段提交的基础上为了解决单点故障问题,增加了超时机制和可中断协议的第三阶段。使其在协调者宕机的时候减少阻塞性。

  3. TCC事务(Try-Confirm-Cancel): 是指一种对CAP原则的妥协,实现最终一致性的一种手段。TCC事务的处理将事务的提交拆分为Try阶段(尝试)、Confirm阶段(确认)和Cancel阶段(取消)。其中Try阶段是尝试执行业务;Confirm阶段是要么都执行,要么都不执行;Cancel阶段是指业务失败后回滚。

  4. 基于消息中间件的分布式事务:使用可靠消息最终一致性(如RabbitMQ等)进行分布式事务的处理。通常是业务逻辑和消息的发送组成了本地事务,在完成本地事务后,发送一条消息,当接受消息者接受到消息后,进行业务操作。

  5. 基于数据库中间件的分布式事务:利用数据库中间件(如MyCAT等)进行分库分表,然后在中间件层进行事务的处理。

  6. Saga分布式事务:Saga是一种长寿命事务设计模式,其中的每个子事务都可以由一个独立的服务进行处理。当所有子事务都正确完成,Saga事务就成功完成。如果在Saga事务中出现错误,Saga事务就会运行补偿事务,以此将系统状态回滚。

以上都是处理分布式事务的一部分方式,具体使用哪一种方式,需要根据业务需求和系统架构来决定。