1、String类为什么不可变?可以写一个类继承String么?
String 类不可变的设计是出于安全性、性能和并发性等考虑。
-
安全性:当一个对象是不可变的时候,它的值在创建后就不能被修改,这样就避免了在多线程环境下可能出现的竞态条件。因此,String 对象可以在并发环境中安全地被共享和使用。
-
性能:由于 String 不可变,可以被缓存和重复利用,这样可以减少内存占用和提高性能。例如,JVM 中的字符串常量池就是利用了这一特性。
-
线程安全:由于 String 是不可变的,它可以被安全地共享,而不需要额外的同步措施。
关于继承 String 类的问题,由于 String 类被 final 修饰,所以无法被继承。你无法创建一个直接继承于 String 的子类。如果你想要扩展 String 类的功能,可以通过组合或者代理的方式来实现,而不是继承 String 类。例如,可以创建一个包含 String 对象作为成员变量的新类,并在其中添加额外的功能。
下面是一个简单的示例,演示如何通过组合方式扩展 String 类的功能:
public class CustomString {
private String value;
public CustomString(String value) {
this.value = value;
}
public int length() {
return value.length();
}
public String getValue() {
return value;
}
public String reverse() {
return new StringBuilder(value).reverse().toString();
}
// 其他自定义方法
public static void main(String[] args) {
CustomString customStr = new CustomString("Hello, World!");
System.out.println("Length: " + customStr.length());
System.out.println("Original Value: " + customStr.getValue());
System.out.println("Reversed Value: " + customStr.reverse());
}
}
在这个示例中,我们创建了一个 CustomString
类,它包含一个 String 类型的成员变量 value
,并提供了一些方法来扩展 String 类的功能,比如计算字符串长度、获取原始值和反转字符串。这样就实现了对 String 类的扩展,同时避免了直接继承 String 类。
2、我看你说到了字符串常量池,那你能介绍一下么?
字符串常量池是 Java 中的一个特殊的内存区域,用于存储字符串常量。在 Java 中,字符串常量池位于堆内存中,其设计的目的是节省内存和提高性能。
当你创建一个字符串常量时,比如使用双引号创建一个字符串字面量:“Hello, World!”,Java 运行时系统会首先检查字符串常量池,如果该字符串已经存在于常量池中,那么将返回常量池中的引用;如果不存在,那么将在常量池中创建新的字符串对象,并返回该引用。
由于字符串常量池的存在,使得相同内容的字符串在内存中只有一份拷贝,这就避免了大量重复字符串对象的创建,节约了内存空间。
在 Java 中,可以使用 intern() 方法来显式地将字符串对象添加到常量池中,这样可以确保相同内容的字符串共享同一个实例。例如:
String s1 = "Hello"; // 字符串常量池中创建了 "Hello"
String s2 = new String("Hello").intern(); // 返回字符串常量池中 "Hello" 的引用
需要注意的是,由于字符串常量池的存在,可能会导致一些意外的内存泄漏问题,因为字符串常量池中的字符串一旦被创建就不会被销毁。因此,在使用字符串常量池时,需要特别注意避免意外的内存泄漏。
3、StringBuff为什么线程安全,两个+号拼接了三个String创建了几个对象?
StringBuffer 是 Java 中用于处理字符串的可变对象,它是线程安全的,主要原因在于它的关键方法都使用了 synchronized 关键字进行同步,确保多个线程访问 StringBuffer 对象时的线程安全性。
关于两个加号拼接三个 String 创建了几个对象的问题,让我们来具体分析一下:
String s1 = "Hello";
String s2 = "World";
String s3 = "!";
String result = s1 + s2 + s3;
在上面的代码中,使用两个加号将三个 String 对象连接起来生成一个新的字符串 result。在这个过程中会创建几个对象呢?让我们来逐步分析:
- 首先,s1、s2 和 s3 分别是三个字符串常量,它们在字符串常量池中各自有一个对象。
- 当执行 s1 + s2 时,会生成一个新的 String 对象,内容为 “HelloWorld”,这个对象存储在堆内存中。
- 接着,将上一步生成的字符串对象与 s3 进行连接,又会生成一个新的 String 对象,内容为 “HelloWorld!”,也存储在堆内存中。
- 最后,将最终的结果字符串赋给 result,result 指向这个新生成的字符串对象。
因此,总共会创建两个新的 String 对象(“HelloWorld” 和 “HelloWorld!”),而原来的 s1、s2、s3 字符串常量对象仍然存在。
4、ArrayList的底层实现?
ArrayList 的底层实现是基于数组的动态扩容机制。在 Java 中,ArrayList 是 List 接口的一个实现类,它提供了动态数组的功能,可以根据需要动态增加或减少数组的大小。
具体来说,ArrayList 内部使用一个 Object 类型的数组来存储元素,默认情况下,该数组的初始大小为 10。当向 ArrayList 中添加元素时,如果当前数组已经满了,ArrayList 就会创建一个新的更大的数组,并将原数组中的元素复制到新数组中。
这种动态扩容的机制保证了 ArrayList 可以根据需要自动调整其内部数组的大小,而不需要手动管理数组的大小。这样就可以方便地向 ArrayList 中添加元素,而不必担心数组大小的限制。
需要注意的是,由于动态扩容需要重新分配内存并复制数据,因此在添加大量元素时可能会带来一些性能开销。另外,由于 ArrayList 使用数组作为底层存储结构,所以在进行大量的插入或删除操作时,可能会导致数组元素频繁地进行移动,从而影响性能。
5、链表实现的数据结构有哪些?
链表是一种常见的数据结构,它有多种实现方式,其中包括:
- 单向链表(Singly Linked List):每个节点包含一个数据元素和一个指向下一个节点的引用。
- 双向链表(Doubly Linked List):每个节点包含一个数据元素、一个指向前一个节点的引用和一个指向下一个节点的引用。
- 循环链表(Circular Linked List):尾节点指向头节点,形成一个环形结构。
- 带头结点链表(Head Linked List):在链表开始处增加一个特殊的结点作为头结点,简化对链表的操作。
除了上述常见的链表实现外,还有其他变种形式的链表,如双向循环链表(Doubly Circular Linked List)、跳表(Skip List)等。
6、讲讲线程池参数?为什么需要核心线程,他的设计目的?
线程池是一种常见的多线程处理方式,它可以在程序初始化时创建一定数量的线程,然后将多个任务提交到线程池中执行。线程池有多个参数,其中比较重要的参数包括以下几个:
- corePoolSize:核心线程数,即线程池初始化时创建的线程数量。当线程池中的线程数小于该值时,新任务会被创建新的线程来处理。
- maximumPoolSize:最大线程数,即线程池中最多可以创建的线程数量。当线程池中的线程数达到该值时,新任务会被放入等待队列中等待处理。
- keepAliveTime:线程空闲时间,即当线程空闲时间超过该值时,多余的线程会被销毁,直到线程池中的线程数量不大于核心线程数。
- workQueue:任务等待队列,即当线程池中的线程数量达到核心线程数时,新任务会被放入等待队列中等待处理。线程池提供了多种不同类型的等待队列,如 SynchronousQueue、LinkedBlockingQueue、ArrayBlockingQueue 等。
线程池的设计目的在于优化线程的创建和销毁,以及控制并发线程的数量,从而提高程序的性能和稳定性。其中,核心线程的设计目的在于保证线程池中始终有一定数量的线程可用,从而避免频繁地创建和销毁线程。这样可以减少线程创建和销毁的开销,提高程序的性能,并且可以避免因线程创建和销毁频繁造成的线程安全问题。核心线程数的设置需要根据实际的应用场景和系统负载情况进行调整,以达到最优的性能和稳定性。
7、让一个线程进入阻塞态有哪些方法?
一个线程可以通过多种方式进入阻塞状态,常见的方法包括:
1. 调用 Object 类的 wait() 方法:当线程调用某个对象的 wait() 方法时,它会释放对象锁并进入阻塞状态,直到其他线程调用该对象的 notify() 或 notifyAll() 方法唤醒它。
Object lock = new Object();
// 线程1
synchronized (lock) {
try {
lock.wait(); // 线程进入阻塞状态
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 线程2
synchronized (lock) {
lock.notify(); // 唤醒线程1
}
2. 调用 Thread 类的 sleep() 方法:线程可以通过调用 sleep() 方法来暂时挂起自己的执行,进入阻塞状态一段指定的时间,然后自动苏醒
// 线程进入阻塞状态一秒钟
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
3. 调用 Thread 类的 join() 方法:在一个线程中调用另一个线程的 join() 方法,该线程将进入阻塞状态,直到被调用的线程执行完毕。
Thread thread = new Thread(() -> {
// 线程执行任务
});
thread.start();
// 主线程等待 thread 线程执行完毕
try {
thread.join(); // 主线程进入阻塞状态
} catch (InterruptedException e) {
e.printStackTrace();
}
4. 阻塞式 I/O 操作:当线程进行阻塞式的 I/O 操作(如读取文件、网络数据等)时,如果没有数据可用,线程会进入阻塞态,直到数据准备好或超时。
InputStream inputStream = socket.getInputStream();
// 阻塞读取数据
byte[] buffer = new byte[1024];
int bytesRead = inputStream.read(buffer); // 线程进入阻塞状态
5. 获取锁时发生竞争:当线程尝试获取一个已被其他线程持有的锁时,它会进入阻塞状态,直到锁可用。
Lock lock = new ReentrantLock();
// 线程1
lock.lock(); // 获取锁
try {
// 执行线程1任务
} finally {
lock.unlock(); // 释放锁
}
// 线程2
lock.lock(); // 线程2尝试获取锁会进入阻塞状态,直到线程1释放锁
try {
// 执行线程2任务
} finally {
lock.unlock(); // 释放锁
}
6. 等待信号量或条件变量:线程可能通过等待信号量或条件变量进入阻塞状态,直到满足特定的条件才能继续执行。这里给出一个简单的示例,使用 CountDownLatch 实现等待:
CountDownLatch latch = new CountDownLatch(1);
// 线程1
latch.await(); // 线程进入阻塞状态
// 线程2
latch.countDown(); // 计数减一,唤醒等待的线程
以上是每种方法的简单代码示例,希望能帮助理解如何让线程进入阻塞状态。
8、Synchronized的抢锁逻辑?
在Java中,使用synchronized
关键字可以实现对代码块或方法的同步(互斥)访问,保证多个线程之间的安全并发执行。当一个线程进入synchronized
代码块时,它会尝试获取锁,如果获取不到锁,则会进入阻塞状态,直到获取到锁为止。下面是synchronized
的抢锁逻辑:
- 当线程尝试进入
synchronized
代码块时,它会首先尝试获取对象的锁。 - 如果该对象的锁当前没有被其他线程持有,那么该线程就会成功获取锁,然后进入临界区执行代码。
- 如果对象的锁已经被其他线程持有,那么当前线程就会进入锁的阻塞队列中等待。
- 一旦持有锁的线程释放了锁(退出
synchronized
代码块),JVM 就会从阻塞队列中选择一个线程唤醒,让它获取到锁,并进入临界区执行代码。
需要注意的是,Java中的synchronized
关键字是可重入的,也就是说,一个线程可以多次获得同一把锁而不会死锁。这意味着,在synchronized
方法内部调用其他synchronized
方法时,是不会出现死锁的。
总的来说,synchronized
的抢锁逻辑是基于对象锁的竞争,只有一个线程能够成功获取锁,其他线程则会进入阻塞状态,直到获取到锁为止。
9、JVM的内存模型
JVM 的内存模型(Java Memory Model,JMM)定义了 Java 程序中各个变量的访问方式、内存间的交互方式以及线程间的协作方式。JMM 规定了 Java 虚拟机中的内存区域、对象的创建与内存回收规则、线程的工作内存与主内存之间的交互等问题。
JMM 将内存分为了三个部分:
- 线程栈(Thread Stack):线程独有的空间,用于存储基本数据类型和对象的引用。
- 堆(Heap):存放对象实例和数组,是共享内存区域。
- 方法区(Method Area):存放类的信息、常量池、静态变量等。
在 JMM 中,线程的工作内存与主内存之间存在一个映射关系。每个线程都有自己的工作内存,工作内存中的变量副本可以与主内存中的变量值不同。当线程需要使用某个变量时,它会先从主内存中读取该变量的值到自己的工作内存中,然后对变量进行操作,最后将修改后的值写回主内存。这样就保证了线程之间的数据可见性。
Java 内存模型通过使用锁(synchronized)、volatile 和 final 等机制来保证多线程程序的正确性。其中,synchronized 可以保证原子性和可见性,volatile 可以保证可见性,而 final 则可以保证不可变性。
总的来说,JVM 的内存模型是 Java 多线程编程中非常重要的概念,它定义了多线程程序中各个变量之间的交互关系,提供了保障多线程程序正确执行的机制。
10、Http下载一个比较大的文件,刚开始下载比较慢,后面速度越来越快为什么?下载一个比较大的文件时,刚开始下载比较慢然后速度逐渐加快可能是由以下几个因素导致的:
-
TCP 慢启动:在建立 TCP 连接时,TCP 协议采用了慢启动算法,即初始时发送窗口大小较小,随着传输成功确认,发送窗口逐渐增大。这意味着刚开始下载时,传输速率较慢,但随着时间的推移,发送窗口逐渐增大,传输速率也会逐渐加快。
-
网络拥塞:刚开始下载时,可能由于网络拥塞、路由器缓冲区满或其他网络问题导致数据传输速度变慢。随着时间的推移,网络拥塞情况可能会得到缓解,从而使得下载速度逐渐增加。
-
服务器限速:有些服务器可能会对单个连接的传输速度进行限制,刚开始下载时速度较慢,随着时间的推移,服务器可能逐渐放宽了限速策略,导致下载速度增加。
-
局部网络状况:有时候局部网络状况可能影响到整体的下载速度,例如路由器、交换机等设备的性能或负载情况。随着时间的推移,这些影响因素可能会逐渐减少,从而导致下载速度加快。
总的来说,下载速度刚开始比较慢然后逐渐加快是正常现象,受到网络协议、网络拥塞、服务器限速以及局部网络状况等多方面因素的影响。
11、单例模式
单例模式是一种常见的设计模式,用于确保一个类只有一个实例,并提供一个全局访问点。
在 Java 中,创建单例可以采用以下三种方式:
1. 饿汉式(线程安全)
在类加载时就创建实例,保证了线程安全。
public class Hungry {
private Hungry() {}
private final static Hungry hungry = new Hungry();
public static Hungry getInstance() {
return hungry;
}
}
2. 懒汉式
2.1. 不加锁
使用时才创建实例,存在多线程安全问题。
public class LazyMan {
private LazyMan() {}
private static LazyMan lazyMan;
public static LazyMan getInstance() {
if (lazyMan == null) {
lazyMan = new LazyMan();
}
return lazyMan;
}
}
2.2. 加锁、volatile、双重检测模式(DCL懒汉式)
通过加锁和双重检测来保证线程安全。
public class LazyMan {
private LazyMan() {}
private volatile static LazyMan lazyMan;
public static LazyMan getInstance() {
if (lazyMan == null) {
synchronized(LazyMan.class) {
if (lazyMan == null) {
lazyMan = new LazyMan();
}
}
}
return lazyMan;
}
}
3. 静态内部类
利用静态内部类的特性,延迟加载实例。
public class Holder {
private Holder() {}
public static Holder getInstace() {
return InnerClass.holder;
}
public static class InnerClass {
private static final Holder holder = new Holder();
}
}