4399 Java ⾯试
4399 ⼩游戏相信都是⼤家的童年回忆,每次 上电脑课,防着⽼师悄悄 打开 4399 ⽹⻚,玩转上⾯
各种琳琅 满⽬的⼩游戏,当时最常同学友⼀起玩的是死神vs ⽕影,两个 ⼈在键盘上敲的⾮常激烈,以⾄于被⽼师发现了,⽼师就直接拔⽹线了。
如果⻓⼤之后没有在关注 4399 ⼩游戏的同学,估计都以为 4399 销声匿迹了,其实⼈家不光活
着,⽽且活得还挺好,并且⻓期在互联⽹百强企业名单⾥,在 2024 年百强企业名单⾥,4399 排
名 46 。
现在 4399 公司规模也有上千⼈了,总部在厦⻔,基本上有双休,⼯作时间是早上九 点到下午六
点,但是毕竟是游戏公司,加班的情况可能还是存在的。
我看了⼀下 4399 开发岗位的校招薪资,⼤概范围是 16k 〜19k x 13 ,也就是年薪在 20w 〜 25w ,薪资在⼆线城市还是⽐较有竞争⼒的。
这次我们来看看 4399 Java 岗的校招⾯经,这次是⼀⾯,问的还是⽐较基础,感觉像⼋股问答
赛,没有算法,问完⼋股就结束了,流程⼤概 20 多分钟。


4399 ⼀⾯
java 是怎么学习的?
学校有开设Java 的课程,除此之外,还看过《Java 并发编程的艺术》、《深⼊理解Java 虚拟机》、《Spring5 实战》、《MySQL 技术内幕》、《Redis 设计 与实现》相关的书籍,同时为了 增加Java 开发
能⼒,做过 xxx 项⽬。
java 语⾔的特点是什么 ?
主要有以下的特点:
平台⽆关性:Java 的“编写⼀次,运⾏⽆处不在”哲学是其最⼤的特点之⼀。Java 编译器将源代码
编译成字节码(bytecode ),该字节码可以在任何 安装了Java 虚拟机的系统上运⾏。
⾯向对象:Java 是⼀⻔严格的⾯向对象编程语⾔,⼏乎⼀切都是对象。⾯向对象编程特性使得
代码更易于维护和重⽤,包括类、对象、继承、多态、抽象和封装。
内存管理:Java 有⾃⼰的垃圾 回收机制,⾃动管理内存和回收不再使⽤的对象。这样,开发者
不需要⼿动管理内存,从⽽减少内存泄漏和其他内存相关的问题。
final 关键字怎么⽤
final 关键字可以⽤来修饰类、⽅法和变量,具有不同的作⽤:
修饰类:将 final 关键字放在类的定义前,如 final class MyClass {...} 。被 final 修饰的类不能被继承。这通常⽤于创建⼀些不 希望被修改或扩 展的类,例如 Java 中的 String 类就是
final 类。
final class FinalClass {
// 类的成员和方法
}
// 以下代码将无法编译
// class SubClass extends FinalClass {}
修饰⽅法:将 final 关键字放在⽅法的声明前,如 public final void myMethod() {...} 。被final 修饰的⽅法不能被⼦类重写。这可以确保⽅法的实现不被修改,通常⽤于保证⼀些关键
⽅法的⾏为在⼦类中不 会改变。
public final void finalMethod() {
System.out.println("This is a final method.");
}
}
class SubClass extends BaseClass {
// 以下代码将无法编译
// public void finalMethod() {
// System.out.println("Trying to override final method.");
// }
}
修饰变量:将 final 关键字放在变量的声明前,如 final int myVariable = 10; 。对于基本数据类型,被 final 修饰的变量⼀旦赋值就不能再修改其值;对于引⽤数据类型(如对象和数
组),被 final 修饰的变量⼀旦引⽤了⼀个对象或数组,就不能再引⽤其他对象或数组,但可
以修改对象或数组的内部状态。
// 修饰基本数据类型
final int number = 5;
// 以下代码将无法编译
// number = 10;
// 修饰对象
final StringBuilder sb = new StringBuilder("Hello");
sb.append(", World"); // 允许修改对象的内部状态
// 以下代码将无法编译
// sb = new StringBuilder("Goodbye");
修饰参数:在⽅法的参数列表中使⽤ final 关键字,如 public void myMethod(final int
parameter) {...} 。被 final 修饰的参数在⽅法内部不能被修改。这可以防⽌在⽅法中不 ⼩⼼修改了传⼊的参数值。
public void printValue(final int value) {
// 以下代码将无法编译
// value = 10;
System.out.println(value);
}使⽤hashmap 时,如果只重写了equals 没有重写hashcode 会出现什么 问
题?
HashMap 存储元素是基于哈希表的原理。它通过 hashCode ⽅法计算元素的哈希值,将元素存储
在对应的哈希桶中。当查找元素时,⾸先根据 hashCode 找到对应的哈希桶,然后在该桶中使⽤
equals ⽅法精确查找元素。
如果只重写了 equals ⽅法⽽未重写 hashCode ⽅法,那么即使两个 对象在逻辑上相等(根据
equals ⽅法的判断),它们的 hashCode 可能不同,这样会导致两个 逻辑上相等的对象可能被存
储在 HashMap 的不同哈 希桶中,因为它们的 hashCode 不同。
当尝试根据键来查 找元素时,可能⽆法找到元素。因为 HashMap ⾸先根据 hashCode 找到哈希
桶,⽽由于 hashCode 不同,会在错误的哈希桶中查找,导致查找失败,即使 equals ⽅法认为
它们相等。
为了 避免这个问题,当重写 equals ⽅法时,应该同时重写 hashCode ⽅法,以保 证对象的逻辑
相等性和存储查找的⼀致性。
深拷⻉和浅拷⻉的区别是什么 ?

浅拷⻉是指只复制对象本⾝和其内 部的值类型字段,但不会复制对象内部的引⽤类型字段。换
句话说,浅拷⻉只是创建⼀个新的对象,然后将原对象的字段值复制到 新对象中,但如果原对
象内部有引⽤类型的字段,只是将引⽤复制到 新对象中,两个 对象指向的是同⼀个引⽤对象。
深拷⻉是指在复制对象的同时,将对 象内部的所有引⽤类型字段的内容也复制⼀份,⽽不是共
享引⽤。换句话说,深拷⻉会递归复制对象内部所有引⽤类型的字段,⽣成⼀个全新的对象以
及其内 部的所有对象。
java 创建对象有哪些⽅式?
创建对象的⽅式有多种,常⻅的包括:
使⽤new 关键字:通过new 关键字直接调⽤类的构造⽅法来创建对象。
MyClass obj = new MyClass();
使⽤Class 类的newInstance() ⽅法:通过反射机制,可以使 ⽤Class 类的newInstance() ⽅法创建对象。
使⽤Constructor 类的newInstance() ⽅法:同样是通过反射机制,可以使 ⽤Constructor 类的
newInstance() ⽅法创建对象。
Constructor<MyClass> constructor = MyClass.class.getConstructor();
MyClass obj = constructor.newInstance();
使⽤clone() ⽅法:如果类实现了Cloneable 接⼝,可以使 ⽤clone() ⽅法复制对象。
MyClass obj1 = new MyClass();
MyClass obj2 = (MyClass) obj1.clone();使⽤反序列化:通过将对 象序列化到⽂件或流中,然后再进⾏反序列化来创建对象。
// SerializedObject.java
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("object.ser"));
out.writeObject(obj);
out.close();
// DeserializedObject.java
ObjectInputStream in = new ObjectInputStream(new FileInputStream("object.ser"));
MyClass obj = (MyClass) in.readObject();
in.close();线程的创建⽅式有哪些?
1、继承Thread 类
这是最直接的⼀种⽅式,⽤⼾⾃定义类继承java.lang.Thread 类,重写其 run() ⽅法,run() ⽅法中定义了 线程执⾏的具体任 务。创建该类的实例后,通过调⽤start() ⽅法启动线程。
class MyThread extends Thread {
@Override
public void run() {
// 线程执行的代码
}
}
MyThread t = new MyThread();
t.start();
}采⽤继承Thread 类⽅式
优点: 编写简单,如果需要访问当前线程,⽆需使⽤Thread.currentThread () ⽅法,直接使⽤this ,即可获得当 前线程
缺点:因为线程类已经继 承了Thread 类,所以不能再继承其他的⽗类
2、实现Runnable 接⼝
如果⼀个类已经继 承了其他类,就不能再继承Thread 类,此时可以实现java.lang.Runnable 接⼝。
实现Runnable 接⼝需要重写run() ⽅法,然后将此Runnable 对象作为参数传递给Thread 类的构造
器, 创建Thread 对象后调⽤其start() ⽅法启动线程。
class MyRunnable implements Runnable {
@Override
public void run() {
// 线程执行的代码
}
}
public static void main(String[] args) {
Thread t = new Thread(new MyRunnable());
t.start();
}采⽤实现Runnable 接⼝⽅式:
优点:线程类只是实现了Runable 接⼝,还可以继承其他的类。在这种⽅式下,可以多个线程共
享同⼀个⽬标对象,所以⾮常适合多个相同线程来处理同⼀份资源的情况,从⽽可以将CPU 代
码和数据分开,形成清晰的模型,较好地体现了⾯向对象的思想。
缺点:编程稍 微复杂,如果需要访问当前线程,必须使⽤Thread.currentThread() ⽅法。- 实现Callable 接⼝与FutureTask
java.util.concurrent.Callable 接⼝类似于Runnable ,但Callable 的call() ⽅法可以有返回值并且可以抛出异常。要执⾏Callable 任务,需将它包装进⼀个FutureTask ,因为Thread 类的构造器只接受Runnable 参数,⽽FutureTask 实现了Runnable 接⼝。
@Override
public Integer call() throws Exception {
// 线程执行的代码,这里返回一个整型结果
return 1;
}
}
public static void main(String[] args) {
MyCallable task = new MyCallable();
FutureTask<Integer> futureTask = new FutureTask<>(task);
Thread t = new Thread(futureTask);
t.start();
try {
Integer result = futureTask.get(); // 获取线程执行结果
System.out.println("Result: " + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}采⽤实现Callable 接⼝⽅式:
缺点:编程稍 微复杂,如果需要访问当前线程,必须调⽤Thread.currentThread() ⽅法。优点:线程只是实现Runnable 或实现Callable 接⼝,还可以继承其他类。这种⽅式下,多个线程
可以共享⼀个target 对象,⾮常适合多线程处理同⼀份资源的情形。
4、使⽤线程池(Executor 框架)
从Java 5 开始引⼊的java.util.concurrent.ExecutorService 和相关类提供了线程池的⽀持,这是⼀种更⾼效的线程管理⽅式,避免了频繁创建和销毁线程的开销。可以通过Executors 类的静态⽅法创建不同类型的线程池。
class MyCallable implements Callable <Integer > {
@Override
public Integer call () throws Exception {
// 线程执行的代码,这里返回一个整型结果
return 1;
}
}
public static void main (String [] args ) {
MyCallable task = new MyCallable ();
FutureTask <Integer > futureTask = new FutureTask <>(task );
Thread t = new Thread (futureTask );
t.start ();
try {
Integer result = futureTask .get (); // 获取线程执行结果
System .out .println ("Result: " + result );
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace ();
}
}
class Task implements Runnable {
@Override
public void run () {
// 线程执行的代码
}
}
public static void main (String [] args ) {
ExecutorService executor = Executors .newFixedThreadPool (10 ); // 创建固定大小的线程池
}
executor.shutdown(); // 关闭线程池
}采⽤线程池⽅式:
缺点:程池增加了程序的复杂度,特别是当涉及线程池参数调整和故障排查时。错误的配置可
能导致死锁、资源耗尽等问题,这些问题的诊断和修复可能较为复杂。
优点:线程池可以重⽤预先创建的线程,避免了线程创建和销毁的开销,显著提⾼了程序的性
能。对于需要快速响应的并发请求,线程池可以迅速提供线程来处理任务,减少等待时间。并
且,线程池能够有效控制运⾏的线程数量,防⽌因创建过多线程导致的系统资源耗尽(如内存
溢出)。通过合理配置线程池⼤⼩,可以最⼤化CPU 利⽤率和系统吞吐 量。
线程的状态有哪些?


源⾃《Java 并发编程艺术》 java.lang.Thread.State 枚举类中定义了 六种线程的状态,可以调⽤线程Thread 中的getState() ⽅法获取当前线程的状态。悲观锁和乐观锁的区别是什么 ?
乐观锁: 就像它的名字⼀样,对于并发间操作产⽣的线程安全问题持乐观状态,乐观锁认为竞
争不 总 是会发⽣,因此它不需要持有锁,将⽐较-替换这两个 动作作 为⼀个原⼦操作尝试去修改
内存中的变量,如果失败则表⽰发⽣冲突,那么就应该有相应的重试逻辑。
悲观锁: 还是像它的名字⼀样,对于并发间操作产⽣的线程安全问题持悲观状态,悲观锁认为
竞争总 是会发⽣,因此每次 对某资源进⾏操作时,都会持有⼀个独占的锁,就像
synchronized ,不管三七 ⼆⼗⼀,直接上了 锁就操作资源了。
MySQL 中的事务隔离级别有哪些?
读未提交(read uncommitted ),指⼀个事 务还没提交时,它做的变更就能被其他事 务看到;
读提交(read committed ),指⼀个事 务提交之 后,它做的变更才能被其他事 务看到;
可重复读(repeatable read ),指⼀个事 务执⾏过程中看到的数据,⼀直跟这个事 务启动时看到
的数据是⼀致的,MySQL InnoDB 引擎的默认隔离级别;
串⾏化(serializable );会对记录加上读写锁,在多个事 务对这条记录进⾏读写操作时,如果发
⽣了读写冲 突的时候,后访问的事务必须等前⼀个事 务执⾏完成,才能继续执⾏;
按隔离⽔平⾼低排序如下:

针对不同的隔离级别,并发事务时可能发⽣的现象也会不同。

也就是说:
在「读未提交」隔离级别下,可能发⽣脏读、不可重复读和幻读现象;
在「读提交」隔离级别下,可能发⽣不可重复读和幻读现象,但是不可能发⽣脏读现象;
在「可重复读」隔离级别下,可能发⽣幻读现象,但是不可能脏 读和不可重复读现象;
在「串⾏化」隔离级别下,脏读、不可重复读和幻读现象都不可能会发⽣。
接下来,举个 具体的例⼦来说明这四种隔离级别,有⼀张账⼾余额表,⾥⾯有⼀条账⼾余额为 100
万的记录。然后有两个 并发的事务,事务 A 只负责 查询余额,事务 B 则会将我的余额改成 200
万,下⾯是按照时间顺序执⾏两个事 务的⾏为:

在不同隔离级别下,事务 A 执⾏过程中查询到的余额可能会不同:
在「读未提交」隔离级别下,事务 B 修改余额后,虽然没有提交事 务,但是此时的余额已经可
以被事务 A 看⻅了,于是事务 A 中余额 V1 查询的值是 200 万,余额 V2 、V3 ⾃然也是 200 万了;
在「读提交」隔离级别下,事务 B 修改余额后,因为没有提交事 务,所以事务 A 中余额 V1 的
值还是 100 万,等事务 B 提交完后,最新的余额数据才能被事务 A 看⻅,因此额 V2 、V3 都是
200 万;
在「可重复读」隔离级别下,事务 A 只能看⻅启动事务时的数据,所以余 额 V1 、余额 V2 的值
都是 100 万,当事务 A 提交事 务后,就能看⻅最新的余额数据了,所以余 额 V3 的值是 200
万;
在「串⾏化」隔离级别下,事务 B 在执⾏将余额 100 万修改为 200 万时,由于此前事务 A 执⾏
了读操作,这样就发⽣了读写冲 突,于是就会被锁住,直到事务 A 提交后,事务 B 才可以继续
执⾏,所以从 A 的⻆度看,余额 V1 、V2 的值是 100 万,余额 V3 的值是 200 万。
这四种隔离级别具体是如何实现的呢?
对于「读未提交」隔离级别的事务来说,因为可以读到未提交事 务修改的数据,所以直接读取
最新的数据就好了;
对于「串⾏化」隔离级别的事务来说,通过加读写锁的⽅式来避免并⾏访问;
对于「读提交」和「可重复读」隔离级别的事务来说,它们是通过 Read View 来实现的,它们
的区别在于创建 Read View 的时机不同,「读提交」隔离级别是在「每个语句执⾏前」都会重新
⽣成⼀个 Read View ,⽽「可重复读」隔离级别是「启动事务时」⽣成⼀个 Read View ,然后
整个事 务期间都在⽤这个 Read View 。
查询当前数据库的事务隔离级别的命令是什么 ?
在 MySQL8.0+ 版本中:

查看当前会话隔离级别: select @@transaction_isolation;
查看系统当前隔离级别: select @@global.transaction_isolation;redis 的常⻅数据结构有 哪些?


Redis 提供了丰 富的数据类型,常⻅的有五种数据类型:String (字符串),Hash (哈希),List
(列表),Set (集合)、Zset (有序集合)。
随着 Redis 版本的更新,后⾯⼜⽀持了四种数据类型:BitMap (2.2 版新增)、HyperLogLog (2.8 版新增)、GEO (3.2 版新增)、Stream (5.0 版新增)。Redis 五种数据类型的应⽤场景:
String 类型的应⽤场景:缓存对象、常规计数、分布式锁、共享 session 信息等。
List 类型的应⽤场景:消息队列(但是有两个 问题:1. ⽣产者需要⾃⾏实现全局唯⼀ ID ;2. 不
能以消费组形式 消费数据)等。
Hash 类型:缓存对象、购物⻋等。
Set 类型:聚合计算(并集、交集、差集)场景,⽐如点赞、共同关注、抽奖活动等。
Zset 类型:排序场景,⽐如排⾏榜、电话和姓名排序等。
Redis 后续版本⼜⽀持四种数据类型,它们的应⽤场景如下:
BitMap (2.2 版新增):⼆值状态统计的场景,⽐如签到、判断⽤⼾登陆状态、连续签到⽤⼾总
数等;
HyperLogLog (2.8 版新增):海量数据基数统计的场景,⽐如百万级⽹⻚ UV 计数等;
GEO (3.2 版新增):存储地理位置信息的场景,⽐如滴滴 叫⻋;
Stream (5.0 版新增):消息队列,相⽐于基于 List 类型实现的消息队列,有这两个 特有的特
性:⾃动⽣成全局唯⼀消息ID ,⽀持以消费组形式 消费数据。
spring 的AOP 的作⽤是什么 ?
Spring AOP ⽬的是对于⾯向对象思维的⼀种补充,⽽不是像引⼊命令式、函数式编程思维让他顺
应另⼀种开发场景。在我个⼈的理解下AOP 更像是⼀种对于不 ⽀持多继承的弥补,除开对象的主
要特征(我更喜欢叫“强共性”)被抽象为了 ⼀条继承链路,对于⼀些“弱共性”,AOP 可以统⼀对他
们进⾏抽象和集中处理。
举⼀个简单的例⼦,打印⽇志。需要打印⽇志可能是许多对象的⼀个共性,这在企业级开发中⼗
分常⻅,但是⽇志的打印并不反应这个对象的主要共性。⽽⽇志的打印⼜是⼀个具体的内容,它
并不抽象,所以它的⼯作也不 可以⽤接⼝来完成。⽽如果利⽤继承,打印⽇志的⼯作⼜横跨继承
树下⾯的多个同级⼦节点,强⾏侵⼊到继承树内进⾏归纳会⼲扰这些强共性的区分。
这时候,我们就需要AOP 了。AOP ⾸先在⼀个Aspect (切⾯)⾥定义了 ⼀些Advice (增强),其中
包含具体实现的代码,同时整理了切⼊点,切⼊点的粒度是⽅法。最后,我们将这些Advice 织⼊
到对象的⽅法上,形成了最后执⾏⽅法时⾯对的完整⽅法。

AOP 常⻅的通知类型有哪些?相关术语解释
在 Spring AOP 中,通知(Advice )是切⾯在特定连接点(Join Point )采取的⾏动,常⻅的通知类型有以下三 种,有“around” ,“before” 和“after” 三种类型。在很多的 AOP 实现框架中,Advice 通常作为⼀个拦截 器, 也可以包含许多个拦截 器作为⼀条链路围绕着 Join point 进⾏处理。
前置通知(Before Advice ):在⽬标⽅法执⾏之前调⽤通知。它可以⽤于执⾏⼀些前置的操
作,例如参数检查、权限验证等。⽐如下⾯的代码使⽤了 @Before 注解,该注解的参数是⼀个
切点表达式,表⽰在 com.example.service.MyService 类中的任何 ⽅法执⾏之前,都会执⾏
beforeAdvice ⽅法。
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
@Aspect
public class MyAspect {
@Before("execution(* com.example.service.MyService.*(..))")
public void beforeAdvice() {
System.out.println("This is before advice. Executing before the target method.");
}
}后置通知(After Advice ):在⽬标⽅法执⾏完成之后调⽤通知,⽆论⽬标⽅法是否正常结束或抛出异常。后置通知通常⽤于释放资源或执 ⾏⼀些清理⼯作。⽐如下⾯的代码,这⾥使⽤了
@After 注解,意味着 afterAdvice ⽅法会在 com.example.service.MyService 类中的任何 ⽅法执⾏之后被调⽤。
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.After;
@Aspect
public class MyAspect {
@After("execution(* com.example.service.MyService.*(..))")
public void afterAdvice() {
System.out.println("This is after advice. Executing after the target method.");
}
}环绕通知(Around Advice ):环绕通知是最强⼤的⼀种通知,它可以在⽬标⽅法调⽤前后⾃定
义操作,并且可以控制⽬标⽅法是否执⾏,以及修改其返回值。环绕通知可以实现更复杂的逻
辑,如事务管理。⽐如下⾯的代码, @Around 注解表明这是⼀个环绕通知,ProceedingJoinPoint 参数表⽰正在执⾏的连接点,可以调⽤ proceed() ⽅法来执⾏⽬标⽅法。环绕通知需要⼿动调⽤ proceed() ⽅法,否则⽬标⽅法不会被执⾏,同时可以对返回值进⾏修改。
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
@Aspect
public class MyAspect {
@Around("execution(* com.example.service.MyService.*(..))")
public Object aroundAdvice(ProceedingJoinPoint pjp) throws Throwable {
System.out.println("This is around advice. Before target method.");
Object result = pjp.proceed(); // 执行目标方法
System.out.println("This is around advice. After target method.");
return result;
}
}linux 命令⾏如何找到占⽤端⼝的进程PID
可以通过 lsof 命令来找到占⽤端⼝的进程 ID ,例如要查找占⽤ TCP 端⼝ 3306 的进程 PID ,可以
执⾏以下命令:
lsof -i :3306 -sTCP:LISTEN

上述命令中, -i 参数⽤于指定要监听的⽹络地址 和端⼝号, : 后⾯跟上端⼝号; -
sTCP:LISTEN 表⽰只列出 状态为监听(LISTEN )的 TCP 连接,这样可以更精准地找到占⽤指定端⼝的进程。执⾏该命令后,如果有 进程占⽤ 3306 端⼝,会显⽰相关进程信息,其中 PID 列即为进
程的 PID 号。
也可以通过 netstat 命令来找到占⽤端⼝的进程 ID ,例如要查找占⽤ TCP 端⼝ 3306 的进程 PID ,可以执⾏以下命令:
netstat -tulnpe | grep :3306
上述命令中, -t 表⽰列出 TCP 连接, -u 表⽰列出 UDP 连接, -l 表⽰只列出 处于监听状态的
连接, -n 表⽰以数字形式 显⽰地址 和端⼝号,避免进⾏ DNS 解析,从⽽加快命令执⾏速度, -
p 表⽰显⽰占⽤该连接的进程 ID 和进程名称, -e 表⽰显⽰扩展信息。管道符 | 将 netstat 命
令的输出作为 grep 命令的输⼊, grep :3306 ⽤于在 netstat 命令的输出结果中查找包
含 :8080 的⾏,即找到占⽤ 3306 端⼝的进程相关信息,其中包含进程的 PID 号。
