1. 写三种单例模式的实现方式
在Java中,单例模式有多种实现方式,以下是三种典型的实现方法,分别适用于不同场景:
1. 饿汉式(Eager Initialization)
特点:类加载时立即初始化实例,线程安全。
代码实现:
public class EagerSingleton {
// 类加载时直接初始化实例
private static final EagerSingleton instance = new EagerSingleton();
// 私有构造方法,防止外部实例化
private EagerSingleton() {}
public static EagerSingleton getInstance() {
return instance;
}
}
优点:实现简单,线程安全。
缺点:实例在类加载时创建,若实例较大或未被使用,可能浪费资源。
2. 双重检查锁(Double-Checked Locking)
特点:延迟初始化,线程安全,且减少同步开销。
代码实现:
public class DCLSingleton {
// 使用volatile禁止指令重排序,保证可见性
private static volatile DCLSingleton instance;
private DCLSingleton() {}
public static DCLSingleton getInstance() {
if (instance == null) { // 第一次检查,避免不必要的同步
synchronized (DCLSingleton.class) {
if (instance == null) { // 第二次检查,确保线程安全
instance = new DCLSingleton();
}
}
}
return instance;
}
}
优点:延迟加载,线程安全,性能较好。
缺点:代码稍复杂,需注意volatile关键字的作用。
3. 静态内部类(Static Inner Class)
特点:利用类加载机制实现延迟加载,线程安全且无需同步。
代码实现:
public class InnerClassSingleton {
private InnerClassSingleton() {}
// 静态内部类在第一次调用getInstance()时才会加载
private static class SingletonHolder {
private static final InnerClassSingleton INSTANCE = new InnerClassSingleton();
}
public static InnerClassSingleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
优点:延迟加载,线程安全,实现简洁。
缺点:无法通过参数构造实例(若需要传递参数)。
补充说明
- 枚举单例(推荐方式):
《Effective Java》推荐通过枚举实现单例,天然防止反射和序列化破坏唯一性:优点:绝对线程安全,代码简洁,但不够灵活(无法延迟加载)。public enum EnumSingleton { INSTANCE; // 添加业务方法 public void doSomething() {} }
根据实际需求选择合适的方式。若需延迟加载且代码简洁,推荐静态内部类;若需绝对安全,优先考虑枚举。
2. 编号为1-n的循环报1-3,报道3的出列,求最后一人的编号。
在Java中,解决约瑟夫环问题(编号1到n循环报数1-3,报3出列,求最后一人编号)可以通过模拟法和数学递推法实现。以下是两种方法的详细实现及分析:
方法1:模拟法(LinkedList实现)
核心思路:用链表动态维护剩余人员,每轮移除第3个位置的元素,直到只剩一人。
时间复杂度:O(n²),适用于n较小的情况。
代码实现:
public static int findLastNumber(int n) {
List<Integer> list = new LinkedList<>();
for (int i = 1; i <= n; i++) list.add(i);
int current = 0; // 当前起始位置索引
while (list.size() > 1) {
current = (current + 2) % list.size(); // 计算要移除的位置(移动2步)
list.remove(current); // 移除当前索引元素
}
return list.get(0);
}
示例分析(n=5):
- 初始链表:
[1, 2, 3, 4, 5] - 每轮移除:3 → 1 → 5 → 2 → 最后剩下4
优点:直观,易于理解。
缺点:n较大时性能较差。
方法2:数学递推法(公式优化)
核心思路:利用递推公式直接计算结果,避免模拟过程。
递推公式:
[ f(n) = (f(n-1) + 3) % n ]
其中 ( f(1) = 0 ),最终结果返回 ( f(n) + 1 )。
时间复杂度:O(n),性能最优。
代码实现:
public static int lastRemaining(int n) {
int res = 0;
for (int i = 2; i <= n; i++) {
res = (res + 3) % i;
}
return res + 1;
}
公式推导:
- 问题转换:将编号从0到n-1重新标记,最终结果加1还原。
- 递推关系:当有i个人时,幸存者位置为 ( (f(i-1) + 3) % i )。
示例分析(n=5): - 计算过程:
( f(2)=1 → f(3)=1 → f(4)=0 → f(5)=3 → 最终结果4 )
优点:高效,适合大n。
缺点:需要理解递推公式的推导。
两种方法对比
| 方法 | 时间复杂度 | 适用场景 | 实现难度 |
|---|---|---|---|
| 模拟法 | O(n²) | n较小,逻辑直观 | 简单 |
| 数学递推法 | O(n) | n较大,追求性能 | 中等 |
测试验证
public static void main(String[] args) {
System.out.println(findLastNumber(5)); // 输出4
System.out.println(lastRemaining(5)); // 输出4
}
总结:
- 面试中建议先写模拟法,再优化到数学法,体现问题分析和优化能力。
- 深入理解递推公式的数学原理是关键,可参考约瑟夫环问题的经典解法。
3. 写两个线程打印1-n,一个线程打印奇数,一个线程打印偶数,顺序不能乱。
以下是两种常见的Java多线程实现方式,用于按顺序交替打印奇偶数:
方法1:synchronized + wait/notify
核心逻辑:通过共享锁控制线程交替执行,使用wait/notify进行线程间通信。
public class OddEvenPrinter {
private static int num = 1; // 当前要打印的数字
private static final int MAX; // 最大值n(需初始化)
private static final Object lock = new Object(); // 共享锁
public static void main(String[] args) {
MAX = 10; // 示例n=10
// 奇数线程
Thread oddThread = new Thread(() -> {
synchronized (lock) {
while (num <= MAX) {
if (num % 2 == 1) { // 判断是否该自己打印
System.out.println("Odd: " + num);
num++;
lock.notify(); // 唤醒偶数线程
} else {
try {
lock.wait(); // 让出锁并等待
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
});
// 偶数线程
Thread evenThread = new Thread(() -> {
synchronized (lock) {
while (num <= MAX) {
if (num % 2 == 0) { // 判断是否该自己打印
System.out.println("Even: " + num);
num++;
lock.notify(); // 唤醒奇数线程
} else {
try {
lock.wait(); // 让出锁并等待
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
});
oddThread.start();
evenThread.start();
}
}
关键点:
- 共享锁:通过
synchronized保证原子性操作。 - 条件判断:通过
num % 2确定当前应打印奇偶。 - 线程协作:
wait()释放锁并等待,notify()唤醒对方线程。
方法2:ReentrantLock + Condition
核心逻辑:利用显式锁和条件变量精准控制线程唤醒。
import java.util.concurrent.locks.*;
public class OddEvenPrinterWithLock {
private static int num = 1;
private static final int MAX = 10;
private static ReentrantLock lock = new ReentrantLock();
private static Condition condition = lock.newCondition();
public static void main(String[] args) {
// 奇数线程
Thread oddThread = new Thread(() -> {
lock.lock();
try {
while (num <= MAX) {
if (num % 2 == 1) {
System.out.println("Odd: " + num);
num++;
condition.signal(); // 唤醒偶数线程
} else {
condition.await(); // 等待
}
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
});
// 偶数线程
Thread evenThread = new Thread(() -> {
lock.lock();
try {
while (num <= MAX) {
if (num % 2 == 0) {
System.out.println("Even: " + num);
num++;
condition.signal(); // 唤醒奇数线程
} else {
condition.await(); // 等待
}
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
});
oddThread.start();
evenThread.start();
}
}
关键点:
- 显式锁:
ReentrantLock提供更灵活的锁控制。 - 条件变量:
Condition实现精准唤醒(对比wait/notify的随机唤醒)。 - 资源释放:
await()自动释放锁,被唤醒后重新获取锁。
测试输出(n=10)
Odd: 1
Even: 2
Odd: 3
Even: 4
...
Odd: 9
Even: 10
两种方法对比
| 方法 | 优点 | 缺点 |
|---|---|---|
| synchronized | 代码简单,适合基础场景 | 无法精准控制唤醒对象 |
| ReentrantLock | 灵活控制唤醒对象,性能更好 | 代码稍复杂,需手动释放锁 |
常见问题
-
为什么用
while而不是if检查条件?
避免虚假唤醒(Spurious Wakeup),确保条件满足后才继续执行。 -
如何保证启动顺序?
不依赖线程启动顺序,由锁机制确保逻辑正确性。 -
如果n很大时性能如何?
ReentrantLock性能更好,但两种方法均能正确工作。
4. 讲一下反射以及应用场景
反射的定义
在 Java 中,反射是指在运行时动态地获取类的信息(如类的属性、方法、构造函数等),并可以在运行时调用这些属性、方法或创建对象的机制。Java 反射机制主要依赖于 java.lang 包中的 Class 类、java.lang.reflect 包中的 Field、Method、Constructor 等类来实现。
获取 Class 对象的三种方式
在使用反射之前,需要先获取目标类的 Class 对象,有以下三种常见方式:
// 方式一:通过类名的 .class 属性
Class<?> clazz1 = String.class;
// 方式二:通过对象的 getClass() 方法
String str = "hello";
Class<?> clazz2 = str.getClass();
// 方式三:通过 Class.forName() 方法
try {
Class<?> clazz3 = Class.forName("java.lang.String");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
反射的基本操作示例
以下是使用反射创建对象、调用方法和访问属性的示例:
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
class Person {
private String name;
public Person() {
}
public Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
public class ReflectionExample {
public static void main(String[] args) throws Exception {
// 获取 Person 类的 Class 对象
Class<?> clazz = Person.class;
// 使用反射创建对象
Constructor<?> constructor = clazz.getConstructor(String.class);
Person person = (Person) constructor.newInstance("John");
// 使用反射调用方法
Method method = clazz.getMethod("getName");
String name = (String) method.invoke(person);
System.out.println("Name: " + name);
// 使用反射访问和修改属性
Field field = clazz.getDeclaredField("name");
field.setAccessible(true);
field.set(person, "Mike");
System.out.println("Updated Name: " + person.getName());
}
}
反射的应用场景
1. 框架开发
- Spring 框架:Spring 通过反射机制实现了依赖注入(DI)和面向切面编程(AOP)。在依赖注入中,Spring 容器会根据配置文件或注解信息,使用反射来创建对象并设置对象的属性。例如,在 Spring 中使用
@Autowired注解进行自动装配时,Spring 会在运行时通过反射找到对应的依赖对象并注入到目标对象中。 - MyBatis 框架:MyBatis 在处理 SQL 映射时,会使用反射机制将查询结果映射到 Java 对象中。通过反射,MyBatis 可以动态地调用对象的 setter 方法来设置属性值。
2. 插件化开发
在一些需要支持插件扩展的系统中,反射可以用于动态加载和使用插件。系统可以在运行时根据配置信息,使用反射来加载插件类,并调用插件的方法。例如,一些开发工具或编辑器的插件系统,通过反射机制可以方便地加载和使用各种插件。
3. 测试框架
在测试框架中,反射可以用于自动调用测试方法。例如,JUnit 框架会通过反射机制扫描测试类中的所有标注了 @Test 注解的方法,并依次调用这些方法进行测试。
4. JSON 数据处理
在将 JSON 数据转换为 Java 对象时,一些 JSON 处理库(如 Gson、Jackson)会使用反射机制来创建对象并设置对象的属性。例如,Jackson 会根据 JSON 数据的键名,通过反射调用 Java 对象的 setter 方法来设置属性值。
5. 代码生成工具
一些代码生成工具会使用反射来分析类的结构,从而生成相应的代码。例如,自动生成 DAO 层代码的工具可以通过反射获取实体类的属性信息,然后生成对应的 SQL 语句和 DAO 方法。
反射的优缺点
优点
- 灵活性高:可以在运行时动态地操作类和对象,增加了程序的灵活性和扩展性。
- 可扩展性强:适用于框架开发和插件化开发,能够方便地实现各种功能扩展。
缺点
- 性能开销大:反射涉及到动态解析和调用,会比直接调用方法和访问属性的性能要低。
- 安全性问题:反射可以绕过访问控制权限,可能会破坏类的封装性,带来一定的安全风险。
5. 说一下如何优化MySQL查询
以下从多个方面详细阐述优化 MySQL 查询的方法:
数据库表结构优化
-
合理设计表结构
- 范式化与反范式化结合:范式化设计可以减少数据冗余,提高数据的一致性,但可能会增加查询时的连接操作。反范式化则是通过适当增加数据冗余来减少连接,提高查询性能。在实际设计中,需要根据具体的业务场景进行权衡。例如,在一个电商系统中,商品表和分类表通常是范式化设计,但在一些统计报表场景下,可以适当反范式化,将分类信息冗余到商品表中。
- 选择合适的数据类型:对于整数类型,如果数据范围较小,优先选择
TINYINT、SMALLINT等,而不是直接使用INT。对于字符串类型,根据实际存储长度选择合适的类型,如VARCHAR用于可变长度字符串,CHAR用于固定长度字符串。
-
优化索引
- 创建合适的索引:根据查询条件和排序规则创建索引,例如在经常用于
WHERE子句、JOIN条件和ORDER BY子句的列上创建索引。例如,对于一个用户表,经常根据用户的注册时间进行查询和排序,那么可以在register_time列上创建索引。 - 避免索引过多:过多的索引会增加数据插入、更新和删除的开销,同时也会占用更多的磁盘空间。一般来说,一个表的索引数量不宜过多,通常控制在 5 - 6 个以内。
- 使用复合索引:当查询条件涉及多个列时,可以创建复合索引。复合索引的顺序要根据查询条件中列的使用频率和选择性来确定,选择性高的列放在前面。例如,对于查询
WHERE col1 = 'value1' AND col2 = 'value2',可以创建复合索引(col1, col2)。
- 创建合适的索引:根据查询条件和排序规则创建索引,例如在经常用于
SQL 查询语句优化
-
避免使用
SELECT *:只查询需要的列,减少数据传输量和数据库的处理负担。例如,将SELECT * FROM users改为SELECT id, name, email FROM users。 -
优化
WHERE子句- 避免在
WHERE子句中使用函数:例如WHERE YEAR(create_time) = 2024会导致索引失效,应改为WHERE create_time BETWEEN '2024-01-01' AND '2024-12-31'。 - 使用
IN代替多个OR:例如WHERE id = 1 OR id = 2 OR id = 3可以改为WHERE id IN (1, 2, 3)。
- 避免在
-
优化
JOIN操作- 确保
JOIN列上有索引:这样可以加快连接的速度。例如,在两个表进行JOIN操作时,确保连接列上都有索引。 - 控制
JOIN的表数量:过多的表连接会增加查询的复杂度和执行时间,尽量减少不必要的表连接。
- 确保
-
优化
ORDER BY和GROUP BY- 确保
ORDER BY和GROUP BY列上有索引:如果ORDER BY和GROUP BY列上有索引,可以避免额外的排序操作。 - 避免在
ORDER BY中使用表达式:例如ORDER BY col1 + col2会导致索引失效,尽量使用单列进行排序。
- 确保
数据库配置优化
-
调整缓冲区大小
innodb_buffer_pool_size:对于 InnoDB 存储引擎,该参数决定了 InnoDB 存储引擎用于缓存数据和索引的内存大小。适当增大该参数可以减少磁盘 I/O,提高查询性能。一般来说,可以将其设置为物理内存的 50% - 75%。key_buffer_size:对于 MyISAM 存储引擎,该参数决定了索引缓存的大小。根据实际情况调整该参数,以提高 MyISAM 表的查询性能。
-
调整查询缓存
query_cache_type:可以设置查询缓存的类型,如0表示禁用查询缓存,1表示启用查询缓存,2表示只对使用SQL_CACHE关键字的查询启用缓存。query_cache_size:设置查询缓存的大小,根据实际情况进行调整。
硬件和操作系统优化
- 升级硬件:增加服务器的内存、使用更快的磁盘(如 SSD)等可以提高数据库的性能。
- 优化操作系统:调整操作系统的参数,如文件系统的块大小、内核参数等,以提高磁盘 I/O 性能。
监控和分析工具
- 使用
EXPLAIN分析查询:EXPLAIN可以帮助我们了解查询的执行计划,包括是否使用了索引、扫描的行数等信息。通过分析EXPLAIN的结果,可以找出查询的瓶颈并进行优化。例如:
收起
sql
EXPLAIN SELECT * FROM users WHERE age > 18;
- 使用 MySQL 自带的性能监控工具:如
SHOW STATUS、SHOW VARIABLES等命令可以查看数据库的状态和配置信息,帮助我们发现性能问题。 - 使用第三方监控工具:如 MySQL Enterprise Monitor、Percona Monitoring and Management(PMM)等,这些工具可以提供更详细的性能监控和分析功能。
6. GET和POST的区别
在 HTTP 协议里,GET 和 POST 是两种常用的请求方法,它们存在多方面的区别,以下从多个维度详细阐述:
语义层面
- GET:主要用于从服务器获取资源。它是一种幂等操作,意味着多次执行相同的 GET 请求,所得到的结果是相同的,不会对服务器上的资源产生实质性的改变。例如,在浏览器中输入网址访问网页,本质上就是发送了一个 GET 请求来获取网页资源。
- POST:通常用于向服务器提交数据,可能会导致服务器上的资源发生创建、更新或删除等操作。比如,在表单中填写信息并提交,往往就是使用 POST 请求将数据发送到服务器进行处理。
数据传递方式
- GET:请求参数会附加在 URL 的后面,通过
?与 URL 分隔,多个参数之间使用&连接。例如:https://example.com/api?name=John&age=30。这种方式会将参数暴露在 URL 中,因此不适合传递敏感信息。 - POST:请求参数会放在 HTTP 请求体中,不会显示在 URL 里。所以,POST 更适合传递包含敏感信息(如密码、信用卡号等)的数据。例如,在使用表单提交用户登录信息时,使用 POST 请求能更好地保护用户的隐私。
数据长度限制
- GET:由于浏览器和服务器对 URL 的长度有限制,所以 GET 请求能携带的数据量也受到限制。不同的浏览器和服务器对 URL 长度的限制不同,一般来说,大多数浏览器允许的 URL 最大长度在 2048 个字符左右。
- POST:理论上对数据长度没有限制,因为数据是放在请求体中的。不过,服务器可能会对请求体的大小进行限制,以防止恶意用户发送过大的请求。
安全性
- GET:由于参数会暴露在 URL 中,容易被他人获取,因此安全性较低。此外,GET 请求还可能被浏览器缓存,这意味着敏感信息可能会被缓存下来,增加了信息泄露的风险。
- POST:参数放在请求体中,不会暴露在 URL 中,相对来说更安全。而且,POST 请求默认不会被浏览器缓存。
缓存机制
- GET:可以被浏览器缓存,当再次请求相同的 URL 时,浏览器可能会直接从缓存中获取数据,而不是重新向服务器发送请求。这在某些情况下可以提高性能,但也可能导致数据不是最新的。可以通过设置响应头(如
Cache-Control、Expires等)来控制缓存策略。 - POST:默认情况下不会被浏览器缓存,每次请求都会向服务器发送数据。不过,在某些特殊情况下,也可以通过设置响应头来实现缓存。
幂等性
- GET:是幂等的,即多次执行相同的 GET 请求,对服务器资源的影响是相同的,不会产生额外的副作用。例如,多次刷新一个网页,网页内容不会因为刷新次数的增加而改变。
- POST:通常不是幂等的,因为每次 POST 请求都可能会对服务器上的资源产生不同的影响。例如,多次提交表单可能会导致多次创建相同的记录。
编码方式
- GET:参数只能使用 ASCII 字符编码,如果需要传递非 ASCII 字符,需要进行 URL 编码。
- POST:可以使用多种编码方式,如
application/x-www-form-urlencoded、multipart/form-data等。application/x-www-form-urlencoded是最常见的编码方式,适用于普通表单数据的提交;multipart/form-data则用于上传文件。