[Java 面试突击训练] 一些 Java 基础面试题

前言

这些问题主要来自 Java 核心部分 ,不涉及 Java EE 相关问题。你可能知道这些棘手的 Java 问题的答案,或者觉得这些不足以挑战你的 Java 知识,但这些问题都是容易在各种 Java 面试中被问到的,都很难回答全面。

具体问题

为什么等待和通知是在 Object 类而不是 Thread 中声明的?

这个问题的好在于它能反映了面试者对等待通知机制的了解,以及他对此主题的理解是否明确。为什么在 Object 类中定义 wait 和 notify 方法,每个人都能说出一些理由。因此,如果你去参加 Java 面试,请确保对 wait 和 notify 机制有充分的了解,并且可以轻松地使用 wait 来编写代码,并通过生产者-消费者问题或实现阻塞队列等了解通知的机制。

为什么等待和通知需要从同步块或方法中调用,以及 Java 中的 wait,sleep 和 yield 方法之间的差异,如果你还没有读过,你会觉得有趣。为何 wait,notify 和 notifyAll 属于 Object 类? 为什么它们不应该在 Thread 类中? 以下是我认为有意义的一些想法:

  • wait 和 notify 不仅仅是普通方法或同步工具,更重要的是它们是 Java 中两个线程之间的通信机制
    对语言设计者而言, 如果不能通过 Java 关键字(例如 synchronized)实现通信此机制,同时又要确保这个机制对每个对象可用,那么 Object 类则是的正确声明位置。记住同步和等待通知是两个不同的领域,不要把它们看成是相同的或相关的。同步是提供互斥并确保 Java 类的线程安全,而 wait 和 notify 是两个线程之间的通信机制。

  • 每个对象都可上锁,这是在 Object 类而不是 Thread 类中声明 wait 和 notify 的另一个原因

  • 在 Java 中为了进入代码的临界区,线程需要锁定并等待锁定
    他们不知道哪些线程持有锁,而只是知道锁被某个线程持有, 并且他们应该等待取得锁, 而不是去了解哪个线程在同步块内,并请求它们释放锁定。

  • Java 是基于 Hoare 的监视器的思想
    在Java中,所有对象都有一个监视器。线程在监视器上等待,为执行等待,我们需要2个参数:一个线程,一个监视器(任何对象)。在 Java 设计中,线程不能被指定,它总是运行当前代码的线程。但是,我们可以指定监视器(这是我们称之为等待的对象)。这是一个很好的设计,因为如果我们可以让任何其他线程在所需的监视器上等待,这将导致“入侵”,导致在设计并发程序时会遇到困难。请记住,在 Java 中,所有在另一个线程的执行中侵入的操作都被弃用了(例如 stop 方法)。

为什么Java中不支持多重继承?

这个 Java 核心问题很难回答,因为你的答案可能不会让面试官满意,在大多数情况下,面试官正在寻找答案中的关键点,如果你提到这些关键点,面试官会很高兴。在 Java 中回答这种棘手问题的关键是准备好相关主题,以应对后续的各种可能的问题。

为什么Java不支持多重继承, 可以考虑以下两点:

  • 第一个原因是围绕钻石形继承问题产生的歧义
    考虑一个类 A 有 foo() 方法, 然后 B 和 C 派生自 A, 并且有自己的 foo() 实现,现在 D 类使用多个继承派生自 B 和 C,如果我们只引用 foo(),编译器将无法决定它应该调用哪个 foo()。这也称为 Diamond 问题,因为这个继承方案的结构类似于菱形。
    [Java 面试突击训练] 一些 Java 基础面试题_第1张图片
    如果你把这个理由告诉面试官,他会问为什么 C++ 和 Python 可以支持多重继承。在 C++ 和 Python 中又称这种情况为二义性,对此 C++ 的解决办法是在调用前指定类名,或者使用同名覆盖。而 Python 也是需要指明需要使用的是哪个父类的方法。并且其实,这两种语言在支持多重继承这个角度上也都建议要把这种多个父类中的出现同名方法的情况给尽量避免掉。

  • 第二个也是更有说服力的理由是,多重继承确实使设计复杂化并在转换、构造函数链接等过程中产生问题
    假设你需要多重继承的情况并不多,简单起见,明智的决定是省略它。此外,Java 可以通过使用接口支持单继承来避免这种歧义。现在,我们想一想,多接口实现为何就不会存在二义性问题?由于接口只有方法声明而且没有提供任何实现(从JDK1.8之后,接口中允许给出一些默认方法的实现,这里不考虑这个),就算一个类实现了多个接口,且这些接口中存在某个同名方法,但是我们要清楚的知道,这个同名方法最终需要由这个类自己来实现,所以并不会出现二义性的问题。然后有的人可能就会问了,不同接口中定义了相同的方法不会造成二义性的问题,但是接口允许定义 public static 的变量啊,那如果变量名一样,不也是会有二义性问题吗?答案是不会,这种情况实际的结果是无法通过编译。

为什么Java不支持运算符重载?

为什么 C++ 支持运算符重载而 Java 不支持? 有人可能会说 “+” 运算符在 Java 中已被重载用于字符串连接,不要被这些论据所欺骗。与 C++ 不同,Java 不支持运算符重载。Java 不能为程序员提供自由的标准算术运算符重载。虽然我不知道背后的真正原因,但我认为以下说法有些道理,为什么 Java 不支持运算符重载。

  • 简单性和清晰性
    清晰性是Java设计者的目标之一。设计者不是只想复制语言,而是希望拥有一种清晰,真正面向对象的语言。添加运算符重载比没有它肯定会使设计更复杂,并且它可能导致更复杂的编译器, 或减慢 JVM,因为它需要做额外的工作来识别运算符的实际含义,并减少优化的机会, 以保证 Java 中运算符的行为。

  • 避免编程错误
    Java 不允许用户定义的运算符重载,因为如果允许程序员进行运算符重载,将为同一运算符赋予多种含义,这将使任何开发人员的学习曲线变得陡峭,事情变得更加混乱。据观察,当语言支持运算符重载时,编程错误会增加,从而增加了开发和交付时间。由于 Java 和 JVM 已经承担了大多数开发人员的责任,如在通过提供垃圾收集器进行内存管理时,因为这个功能增加污染代码的机会, 成为编程错误之源, 因此没有多大意义。

  • JVM 复杂性
    从JVM的角度来看,支持运算符重载使问题变得更加困难。通过更直观,更干净的方式使用方法重载也能实现同样的事情,因此不支持 Java 中的运算符重载是有意义的。与相对简单的 JVM 相比,复杂的 JVM 可能导致 JVM 更慢,并为保证在 Java 中运算符行为的确定性从而减少了优化代码的机会。

  • 让开发工具处理更容易
    这是在 Java 中不支持运算符重载的另一个好处。省略运算符重载使语言更容易处理,这反过来又更容易开发处理语言的工具,例如 IDE 或重构工具。Java 中的重构工具远胜于 C++。

为什么 String 在 Java 中是不可变的?

这个问题可能有很多可能的答案,而 String 类的唯一设计者可以放心地回答它。我认为以下几点解释了为什么 String 类在 Java 中是不可变的或 final 的。

  • String 对象缓存在 String 池中
    一个字符串对象,例如 “Test” 已被许多参考变量引用,因此如果其中任何一个更改了值,其他参数将自动受到影响。假设 String A="Test"; String B="Test"; 现在字符串 B 调用 “Test”.toUpperCase(), 将同一个对象改为“TEST”,所以 A 也是 “TEST”,这不是期望的结果。

  • 字符串已被广泛用作许多 Java 类的参数
    例如,为了打开网络连接,你可以将主机名和端口号作为字符串传递,你可以将数据库 URL 作为字符串传递, 以打开数据库连接,你可以通过将文件名作为参数传递给 File I/O 类来打开 Java 中的任何文件。如果 String 是可变的,这将导致严重的安全威胁,意思是有人可以访问他有权授权的任何文件,然后可以故意或意外地更改文件名并获得对该文件的访问权限。由于不变性,你无需担心这种威胁,Java设计者确保没有人覆盖 String 类的任何行为。

  • 可以安全地共享许多线程
    这对于多线程编程非常重要,并且避免了 Java 中的同步问题,不变性也使得 String 实例在 Java 中是线程安全的,这意味着你不需要从外部同步 String 操作。关于 String 的另一个要点是由截取字符串 substring 引起的内存泄漏,这不是与线程相关的问题,但也是需要注意的。

  • 允许 String 缓存其哈希码
    Java 中的不可变 String 缓存其哈希码,并且不会在每次调用 String 的 hashcode 方法时重新计算,这使得它在 Java 中的 HashMap 中使用的 HashMap 键非常快。简而言之,因为 String 是不可变的,所以没有人可以在创建后更改其内容,这保证了 String 的 hashCode 在多次调用时是相同的。

  • String 被类加载机制使用,因此具有深刻和基本的安全考虑
    如果 String 是可变的,加载“java.io.Writer” 的请求可能已被更改为加载 “mil.vogoon.DiskErasingWriter”。

为什么 char 数组比 Java 中的 String 更适合存储密码?

这是一个真正艰难的核心Java面试问题,并且需要对 String 的扎实知识才能回答这个问题。如果你还没有遇到过这种情况,那么字符数组和字符串都可以用来存储文本数据,但是要说清楚选择一个而不是另一个很难。任何与 String 相关的问题都必须对字符串的特殊属性有一些线索,比如不变性,他用它来说服访提问的人。在这里,我们将探讨为什么你应该使用char[] 存储密码而不是 String 的一些原因。

  • String 缓存池原因
    由于字符串在 Java 中是不可变的,如果你将密码存储为纯文本,它将在内存中可用,直到垃圾收集器清除它。并且为了可重用性,它会存在字符串池中,很可能会保留在内存中持续很长时间,从而构成安全威胁。由于任何有权访问内存转储的人都可以以明文形式找到密码,这是另一个原因,你应该始终使用加密密码而不是纯文本。由于字符串是不可变的,所以不能更改字符串的内容,因为任何更改都会产生新的字符串,而如果你使用char[],你就可以将所有元素设置为空白或零。因此,在字符数组中存储密码可以明显降低窃取密码的安全风险。

  • Java 语言的建议
    Java 本身建议使用 JPasswordField 的 getPassword() 方法,该方法返回一个 char[] 和不推荐使用的 getTex() 方法,该方法以明文形式返回密码,由于安全原因。应遵循 Java 团队的建议,坚持标准而不是反对它。

  • 避免日志打印问题
    使用 String 时,总是存在在日志文件或控制台中打印纯文本的风险,但如果使用 Array,则不会打印数组的内容而是打印其内存位置。虽然不是一个真正的原因,但仍然有道理。我还建议使用散列或加密的密码而不是纯文本,并在验证完成后立即从内存中清除它。因此,在Java中,用字符数组用存储密码比字符串是更好的选择。虽然仅使用 char[] 还不够,还你需要擦除内容才能更安全。

String strPassword =“Unknown”; 
char [] charPassword = new char [] {'U''n''k''w''o''n'}; 
System.out.println("字符串密码:" + strPassword);
System.out.println("字符密码:" + charPassword);

// 输出:
字符串密码:Unknown
字符密码:[C @110b053

如何使用双重检查锁定在 Java 中创建线程安全的单例?

这个 Java 问题也常被问:什么是线程安全的单例,你怎么创建它。好吧,在Java 5之前的版本,使用双重检查锁定创建单例 Singleton 时,如果多个线程试图同时创建 Singleton 实例,则可能有多个 Singleton 实例被创建。从 Java 5 开始,使用 Enum 创建线程安全的Singleton很容易。但如果面试官坚持双重检查锁定,那么你必须为他们编写代码。记得使用volatile变量。

  • 双检查锁定的单例
    下面的代码是单例模式中双重检查锁定的示例,此处的 getInstance() 方法检查两次,以查看 INSTANCE 是否为空,这就是为什么它被称为双检查锁定模式。我们看到双重校验锁即实现了延迟加载,又解决了线程并发问题,同时还解决了执行效率问题,是否真的就万无一失了呢?这里要提到Java中的指令重排优化。所谓指令重排优化是指在不改变原语义的情况下,通过调整指令的执行顺序让程序运行的更快。JVM 中并没有规定编译器优化相关的内容,也就是说JVM 可以自由的进行指令重排序的优化。
    这个问题的关键就在于由于指令重排优化的存在,导致初始化 Singleton 和将对象地址赋给 INSTANCE 字段的顺序是不确定的。在某个线程创建单例对象时,在构造方法被调用之前,就为该对象分配了内存空间并将对象的字段设置为默认值。此时就可以将分配的内存地址赋值给 INSTANCE 字段了,然而该对象可能还没有初始化。若紧接着另外一个线程来调用 getInstance,取到的就是状态不正确的对象,程序就会出错。以上就是双重校验锁会失效的原因,不过还好在 JDK1.5 之后版本增加了 volatile 关键字。volatile 的一个语义是禁止指令重排序优化,也就保证了 INSTANCE 变量被赋值的时候对象已经是初始化过的,从而避免了上面说到的问题。
public class Singleton {
    private static volatile Singleton INSTANCE = null;
    private Singleton() {}
    public static Singleton getInstance() {
        if (INSTANCE == null) {
            synchronized (Singleton.class) {
                if (INSTANCE == null) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return instance;
    }
}
  • 枚举的单例
    这是我们通常声明枚举的单例的方式,它可能包含实例变量和实例方法。但为了简单起见,我没有使用任何实例方法,只是要注意,如果你使用的实例方法且该方法能改变对象的状态的话,则需要确保该方法的线程安全。默认情况下,创建枚举实例是线程安全的,但 Enum 上的任何其他方法是否线程安全都是程序员的责任。
public enum Singleton{
    INSTANCE;
    public void whateverMethod() {}
}
  • 静态内部类的单例
    这种方式同样利用了类加载机制来保证只创建一个 INSTANCE 实例。它与饿汉模式一样,也是利用了类加载机制,因此不存在多线程并发的问题。不一样的是,它是在内部类里面去创建对象实例。这样的话,只要应用中不使用内部类,JVM 就不会去加载这个单例类,也就不会创建单例对象,从而实现懒汉式的延迟加载。也就是说这种方式可以同时保证延迟加载和线程安全。
public class Singleton{
    private static class SingletonHolder{
        public static Singleton INSTANCE = new Singleton();
    }
    private Singleton() {}
    public static Singleton newInstance(){
        return SingletonHolder.INSTANCE;
    }
}

编写 Java 程序时, 如何在 Java 中创建死锁并修复它?

经典但核心 Java 面试问题之一。如果你没有参与过多线程并发 程序的编码,你可能会失败。

// 死锁参考代码
public class DeadLockDemo {
	// 此方法请求两个锁,第一个字符串,然后整数
	public void method1() {
        synchronized (String.class) {
            System.out.println("Aquired lock on String.class object");

            synchronized (Integer.class) {
                System.out.println("Aquired lock on Integer.class object");
            }
        }
    }
    
    // 相反的顺序,即首先整数,然后字符串
	public void method2() {
        synchronized (Integer.class) {
            System.out.println("Aquired lock on Integer.class object");

            synchronized (String.class) {
                System.out.println("Aquired lock on String.class object");
            }
        }
    }	
}

如果 method1() 和 method2() 都由两个或多个线程调用,则存在死锁的可能性,因为如果线程 1 在执行 method1() 时在 Sting 对象上获取锁,线程 2 在执行 method2() 时在 Integer 对象上获取锁,等待彼此释放 Integer 和 String 上的锁以继续进行一步,但这永远不会发生。

如果你仔细查看了上面的代码,那么你可能已经发现死锁的真正原因不是多个线程,而是它们请求锁的方式,如果你提供有序访问,则问题将得到解决。

public class DeadLockFixed {
    /**
     * 两种方法现在都以相同的顺序请求锁,首先采用整数,然后是 String。
     * 你也可以做反向,例如,第一个字符串,然后整数,
     * 只要两种方法都请求锁定,两者都能解决问题
     * 顺序一致。
     */
    public void method1() {
        synchronized (Integer.class) {
            System.out.println("Aquired lock on Integer.class object");

            synchronized (String.class) {
                System.out.println("Aquired lock on String.class object");
            }
        }
    }

    public void method2() {
        synchronized (Integer.class) {
            System.out.println("Aquired lock on Integer.class object");

            synchronized (String.class) {
                System.out.println("Aquired lock on String.class object");
            }
        }
    }
}

谈谈你对序列化的认识?

  • Java 中的可序列化接口和可外部接口之间的区别是什么?
    这是 Java 序列化访谈中最常问的问题。给我们提供 writeExternal() 和 readExternal() 方法,这让我们灵活地控制 Java 序列化机制,而不是依赖于 Java 的默认序列化。正确实现 Externalizable 接口可以显著提高应用程序的性能。

  • 可序列化的方法有多少?如果没有方法,那么可序列化接口的用途是什么?
    可序列化 Serializalbe 接口存在于 java.io 包中,构成了 Java 序列化机制的核心。它没有任何方法,在 Java 中也称为标记接口。当类实现 java.io.Serializable 接口时,它将在 Java 中变得可序列化,并指示编译器使用 Java 序列化机制序列化此对象。

  • 什么是 serialVersionUID ?如果你不定义这个, 会发生什么?
    erialVersionUID 是一个 private static final long 型 ID,当它被印在对象上时,它通常是对象的哈希码,你可以使用 serialver 这个 JDK 工具来查看序列化对象的 serialVersionUID。SerialVerionUID 用于对象的版本控制。也可以在类文件中指定 serialVersionUID。不指定 serialVersionUID的后果是,当你添加或修改类中的任何字段时,则已序列化类将无法恢复,因为为新类和旧序列化对象生成的 serialVersionUID 将有所不同。Java 序列化过程依赖于正确的序列化对象恢复状态的,并在序列化对象序列版本不匹配的情况下引发 java.io.InvalidClassException 无效类异常。

  • 序列化时,你希望某些成员不要序列化?你如何实现它?
    另一个经常被问到的序列化面试问题。这也是一些时候也问,如什么是瞬态 trasient 变量,瞬态和静态变量会不会得到序列化等。所以,如果你不希望任何字段是对象的状态的一部分,然后声明它静态或瞬态,这样就不会是在 Java 序列化过程中被包含在内。

  • 如果类是可序列化的,但其超类不是,则反序列化后从超类继承的实例变量的状态如何?
    Java 序列化过程仅在对象层次都是可序列化结构中继续,即实现 Java 中的可序列化接口,并且从超类继承的实例变量的值将通过调用构造函数初始化。一旦构造函数链接启动,就不可能停止。因此,即使层次结构中较高的类实现可序列化接口,也将执行构造函数。正如你从陈述中看到的,这个序列化面试问题看起来非常棘手和有难度,但如果你熟悉关键概念则并不难。

  • 是否可以自定义序列化过程,或者是否可以覆盖 Java 中的默认序列化过程?
    答案是肯定的,可以。我们都知道,对于序列化一个对象需调用 ObjectOutputStream.writeObject(),并用 ObjectInputStream.readObject() 读取对象。但 Java 虚拟机为你提供的还有一件事,是定义这两个方法。如果在类中定义这两种方法,则 JVM 将调用这两种方法,而不是应用默认序列化机制。你可以在此处通过执行任何类型的预处理或后处理任务来自定义对象序列化和反序列化的行为。

  • 假设新类的超类实现可序列化接口,如何避免新类被序列化?
    如果类的 Super 类已经在 Java 中实现了可序列化接口,那么它在 Java 中已经可以序列化,因为你不能取消接口,它不可能真正使它无法序列化类。但是有一种方法可以避免新类序列化。为了避免 Java 序列化,你需要在类中实现 writeObject() 和 readObject() 方法,并且需要从该方法引发不序列化异常NotSerializableException。

  • 在 Java 中的序列化和反序列化过程中使用哪些方法?
    这是很常见的面试问题,在序列化上面试官试图知道:你是否熟悉 readObject() 的用法、writeObject()、readExternal() 和 writeExternal()。Java 序列化由 java.io.ObjectOutputStream 类完成。该类是一个筛选器流,它封装在较低级别的字节流中,以处理序列化机制。要通过序列化机制存储任何对象,我们调用 ObjectOutputStream.writeObject(),并反序列化该对象。我们称之为 ObjectInputStream.readObject() 方法。调用 writeObject() 方法在 java 中触发序列化过程。关于 readObject() 方法,需要注意的一点很重要一点是,它用于从持久性读取字节,并从这些字节创建对象,并返回一个对象,该对象需要类型强制转换为正确的类型。

  • 假设你有一个类,它序列化并存储在持久性中,然后修改了该类以添加新字段。如果对已序列化的对象进行反序列化,会发生什么情况?
    这取决于类是否具有其自己的 serialVersionUID。正如我们从上面的问题知道,如果我们不提供 serialVersionUID,则 Java 编译器将生成它,通常它等于对象的哈希代码。通过添加任何新字段,有可能为该类新版本生成的新 serialVersionUID 与已序列化的对象不同。在这种情况下,Java 序列化 API 将引发 java.io.InvalidClassException,因此建议在代码中拥有自己的 serialVersionUID,并确保在单个类中始终保持不变。

你能用 Java 覆盖静态方法吗?如果我在子类中创建相同的方法是编译时错误?

你不能覆盖Java中的静态方法,因为方法覆盖基于运行时的动态绑定,静态方法在编译时使用静态绑定进行绑定。虽然可以在子类中声明一个具有相同名称和方法签名的方法,看起来可以在Java中覆盖静态方法,但实际上这是方法隐藏。Java不会在运行时解析方法调用,并且根据用于调用静态方法的 Object 类型,将调用相应的方法。这意味着如果你使用父类的类型来调用静态方法,那么原始静态将从父类中调用,另一方面如果你使用子类的类型来调用静态方法,则会调用来自子类的方法。简而言之,你无法在Java中覆盖静态方法。`

你可能感兴趣的:(Java,面试)