Java的异常体系

分为异常Exception和错误Error,异常是可以用try/catch来解决的,但是error就很严重了,会直接导致线程终止。他们都继承自ThrowAble接口。

其中exception分为可以检查到的异常(checked exception),表示能够在编译阶段就发现并且抛出错误的异常,比如sqlexception或者ioexception;编译时无法检测出的异常(unchecked exception),指的就是运行时异常(runtimeException)例如数组越界,空指针这种。 runtimeException都是无法检测出的异常,其余的都是可以在编译阶段检测出来的。

error是比较严重的错误,例如oom或者stackoverflow,还有虚拟机内部的一些问题,这些error会导致线程直接终止,并不能用try/catch

gpt了一下,其实error也可以用catch,因为都是继承自throwable接口,throwable满足这个语法糖。但是出现了error就算catch了程序还是出现了严重错误,捕获这些问题并不能修复,继续运行可能导致更加严重的问题。

finally方法块

  • finally并不一定会执行:如果出现了电源掉电或者cpu卡死就不会(这有点幽默了
  • 不建议在finally里面写返回:try/catch/finally方法块的逻辑是,即使在try中已经返回了,也会执行finally里面的语句,try的返回值保存起来,同时如果finally里面也有return,就会覆盖try的返回值。

序列化和反序列化

Java数据的传输是通过字节流的,也就是一个byte[]数组,如何将Java转化为这个数组就是序列化要干的事情,原生提供的序列化可以提供实现serialization接口并且指定serialVersionUID完成序列化,这个serialVersionUID会为反序列化提供一个校验,如果id与当前要强制类型转换的类是一样的,那就可以转换,否则就会抛出异常。

比如下面这个代码,文件模拟的是传输的数据流。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import java.io.*;

class Person implements Serializable {
private static final long serialVersionUID = 1L; // 显式定义版本号
String name;
int age;

public Person(String name, int age) {
this.name = name;
this.age = age;
}
}

public class Main {
public static void main(String[] args) {
try {
// 序列化
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("person.ser"));
Person person = new Person("Alice", 30);
out.writeObject(person);
out.close();

// 模拟反序列化后类发生变化
// 修改 serialVersionUID 为 2L,模拟版本不匹配
// private static final long serialVersionUID = 2L;

// 反序列化
ObjectInputStream in = new ObjectInputStream(new FileInputStream("person.ser"));
Person deserializedPerson = (Person) in.readObject(); // 读取对象
in.close();

System.out.println("Deserialized Person: " + deserializedPerson.name + ", " + deserializedPerson.age);
} catch (Exception e) {
e.printStackTrace(); // 如果 serialVersionUID 不匹配,这里会抛出 InvalidClassException
}
}
}

注意几点:

  1. 序列化转化的是对象,也就是说static修饰的字段是不会被序列化的,反正最终反序列化变成了一个对象之后类的静态属性又不会有变化,
  2. 不想序列化的变量用transient修饰,例如下面这个代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import java.io.*;

class Example implements Serializable {
private static final long serialVersionUID = 1L;

static int staticField = 10; // static 字段
transient int transientField = 20; // transient 字段
int instanceField = 30; // 实例字段

public static void main(String[] args) {
Example example = new Example();

try {
// 序列化对象
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("example.ser"));
out.writeObject(example);
out.close();

// 修改 staticField 值以查看序列化和反序列化的影响
Example.staticField = 100;

// 反序列化对象
ObjectInputStream in = new ObjectInputStream(new FileInputStream("example.ser"));
Example deserializedExample = (Example) in.readObject();
in.close();

// 打印结果
System.out.println("Deserialized Example:");
System.out.println("Static Field: " + Example.staticField); // staticField 仍然是 100
System.out.println("Instance Field: " + deserializedExample.instanceField); // instanceField 是 30
System.out.println("Transient Field: " + deserializedExample.transientField); // transientField 是 0
} catch (Exception e) {
e.printStackTrace();
}
}
}

但是Java默认的这个序列化并不好,至少我在开发的时候都用的是Json,为什么呢?

  1. 不支持跨平台,java序列化得到的字节流只能java反序列化出来,json不一样,java转化为的json可以用python再反序列化回来
  2. 性能较差,转化后的字节数组大
  3. 安全性

String,StringBuffer,StringBuilder

  • string是被final修饰的,同时内部的实现数组(char[]或者byte[])是private,无法通过外界修改。因此字符串的拼接是需要新生成对象的。
  • stringbuffer:方法由synchronized修饰,是线程安全的,其拼接字符串不会创建一个新的stringbuffer类,效率比string不知道高到哪里去了
  • stringbuilder:在大部分情况下string的操作都不太需要线程同步,这个类就取消了同步操作,效率比上一个高。

字符串的拼接

java是没有运算符重载的,但是唯一为了string的拼接修改了+和+=,使得string可以通过+和+=操作

查看字节码我们可以知道,其实string的+和+=是基于stringbuilder调用append方法,最后再使用tostring赋值给String,但是每次进行这样一个拼接操作就需要new一个stringbuilder,对于循环来说,这样的开销就非常大了。

1
2
3
4
5
6
String[] arr = {"he", "llo", "world"};
String s = "";
for (int i = 0; i < arr.length; i++) {
s += arr[i];
}
System.out.println(s);

如果直接使用append来拼接,就不会有这个问题了。所以在大量字符串操作的时候用buffer或者builder比较好。

字符串常量池和String对象

字符串常量池
首先做个区分,java程序在编译成了class文件之后会有一个常量池,也叫做静态常量池。当在被类加载器加载进内存时会将其分为字符串常量池和运行时常量池。

  • 运行时常量池是一直跟着方法区的,方法区在哪里它在哪里。在jdk1.7的时候方法区叫做永久代,放在运行时数据区,也就是和堆栈在一起的区域,为了减少oom的可能和内存的压力,在jdk1.8以后被搬到了直接内存中,由操作系统管理不由jvm虚拟机管理,从而减少了jvm的内存压力。但是虚拟机参数可以配置元空间的大小,并不是可以无限扩大的,也会有oom:metaspace的报错。说回运行时常量池,包含了class文件里静态常量池里的字面量和符号引用,但唯独没有字符串。也就是说除字符串以外的字面量比如整形和浮点,符号引用的类与接口全限定名,方法和字段的名称和描述符,都还在运行时常量池;维度字符串排除出去了
  • 字符串常量池:在jdk1.6的时候是没有与运行时常量池分开来的,也是跟着方法区的,到了1.7被分到堆内存去了。原因是本来方法区进行垃圾回收的机会就很少,除了fullgc基本到不了这里,字符串又是一个很容易产生大量对象的地方。所以就放到heap中了,可以进行垃圾回收,这里创建出的也被称之为字符串对象。实际上String的引用都是在字符串常量池里的地址。

new String(“abc”)的问题

1
String s1 = new String("abc");

我们假设前面都没有关于abc这个字符串的创建,这个操作会创建两个对象:

  • 首先会在字符串常量池上创建abc这个对象
  • 由于new了一个String对象,会在堆上创建一个新的对象

但是如果前面有String s2=”abc”,也就是前面已经有在字符串常量池里面创建对象了,前面这个new的代码只会创建一个对象,即堆内存上的。

1
2
3
4
String s1 = "abc";
String s2 = "abc";

System.out.println(s1 == s2); // true,因为 s1 和 s2 都引用常量池中的同一个对象
1
2
3
4
5
String s1 = "abc";              // 常量池中的 "abc" 对象
String s2 = new String("abc"); // 堆中的新对象

System.out.println(s1 == s2); // false,s1 和 s2 是不同的对象,s1 是常量池中的,s2 是堆中的

那我在new了一个String以后,我怎么找到字符串常量池里面的对象?
intern可以直接找,返回的是在字符串常量池里的地址,这样就保证了字符串内容是一致的情况下,地址也完全一样,在某些要用锁的场合下,使用intern可以保证锁的对象是唯一的。
例如在黑马点评这个锁代码块里面:

1
2
3
4
5
6
7
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()){
...
}
}

这里面每一个id的toString都会创建一个新的对象,为了保证锁对象的唯一性,需要用intern直接取出常量池的地址

为什么要有这种设计?字符串常量池本身就是为了可重复才扩展的,有的时候不想要与字符串的常量池共享,就需要创建一个新的字符串。

Double和BigDecimal的区别

  • double是基本数据类型,bigDecimal是类
  • double双精度浮点型可能会有数据溢出的情况,bigdecimal是用字符串做运算的,精度有保障,由于是字符串,会频繁创建对象,开销较大
  • 精度要求特别高比如在金融领域需要用big

反射

类加载器加载字节码文件分为三部分:通过类的全限定名获取字节码文件,将字节码文件生成一个InstanceKlass文件在方法区,保存了类的元数据包括字段,方法,虚方法表登;同时会在堆中生成一个Class对象,作为访问这个InstanceKlass对象的接口。 正是这个Class对象为Java反射提供了基础。

获取Class对象有以下四种方式:

  1. Class.forName传入路径
  2. 通过对象获取class(instance.getClass())
  3. 通过类加载器传入loadClass路径获取(走的就是双亲委派机制那一套了,估计findClass是根据路径改造成全限定名然后找,但是这里仅仅是加载类,没有连接过程,所有静态代码块不会执行)
  4. 可以直接.class获取
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;

public class ReflectionExample {
public static void main(String[] args) {
try {
// 1. 获取Class对象
Class<?> clazz = Class.forName("com.example.Person");

// 2. 获取构造器并创建对象
Constructor<?> constructor = clazz.getConstructor(String.class, int.class);
Object person = constructor.newInstance("张三", 25);

// 3. 获取并调用方法
Method sayHello = clazz.getMethod("sayHello");
sayHello.invoke(person);

// 4. 获取并修改字段
Field ageField = clazz.getDeclaredField("age");
ageField.setAccessible(true); // 如果字段是private的,需要设置可访问
int age = (int) ageField.get(person);
System.out.println("年龄: " + age);
ageField.set(person, 30);
System.out.println("修改后的年龄: " + ageField.get(person));

} catch (Exception e) {
e.printStackTrace();
}
}
}

// 假设存在如下Person类
package com.example;

public class Person {
private String name;
private int age;

public Person(String name, int age) {
this.name = name;
this.age = age;
}

public void sayHello() {
System.out.println("你好,我是" + name + ",今年" + age + "岁。");
}
}

如何获得私有变量/方法:setAccessible

反射的优缺点

  • 优点:灵活度高,多种框架都是基于反射的,能够在运行时动态操作类和对象
  • 缺点:安全性,private字段可以访问,性能开销

总结:为什么反射的效率低?
反射效率低的原因可以归结为以下几点:

  • 需要进行动态的类型解析,耗费额外时间。
  • 反射的调用绕过了编译时的优化。
  • 反射需要执行额外的安全性检查。
  • 方法调用的间接性导致了额外的步骤和开销。
  • 缺乏JIT对反射的优化支持。
  • 频繁使用时,开销会明显累积。
  • 反射可能会引发对象包装和拆箱操作,进一步降低效率。

switch能否用String当作case

在jdk1.7以后有了这个语法糖。在底层原理中switch仅支持int,char等基本数据类型。switch中string是通过case哈希值,然后在equals内容得到的。哈希值是一个int类型的,由于也可能产生哈希碰撞,所以还得再检查字符串内容是否相同。

泛型

虚拟机中没有泛型,只有普通类和普通方法,所有泛型类的类型参数在编译时都会被擦除,泛型类并没有自己独有的Class类对象。比如并不存在List.class或是List.class,而只有List.class。

也就是说泛型仅仅是在编译的时候做检查,而不会真正落到jvm里

Object有哪些方法

  • wait和notify以及notifyAll:wait方法是放弃当前监视器锁,进入waitSet,状态变为WAIT。notify和notifyAll是唤醒当前监视器锁下面wait的线程,在hotspot中是分别是随机唤醒一个和全部唤醒。这个方法仅能在synchronized方法块中使用
  • hashCode和equals:下面讲
  • getClass:获取Class对象,与类.class方法是一样的,一共有四种方法获取Class对象(getClass,.class,类加载器loadClass,Class.forName)

hashCode和equals

说这两个就离不开集合,例如hashMap存储是按照hashCode散列找Entry,如果hashCode相同则在一个Entry下,但是有可能是哈希碰撞,所以还要比较equals,当且仅当hashCode相等并且equals返回为真的时候才会被认为是一个。在Object中默认hashCode是两个对象的内存散列,equals是比较内存地址是否相同。

String重写的hashCode和equals

1
2
3
4
String s = "abc";
String s1 = new String("abc");
System.out.println(s.hashCode()+" "+s1.hashCode());
System.out.println(s.equals(s1));

按道理来说s和s1是两个不同的对象,由于之前说的s应该是在字符串常量池,而s1是在堆中,hashCode按照Object默认的比较地址肯定是不相同的。但是String重写了hashCode,通过字符累加的方式计算哈希码,在保证了分散的同时使得相同字符串的哈希码相等。也重写了equals,比较字符串内容而不是比较地址。

为什么重写了equals就必须重写hashCode

如果两个对象内容相同就是一个对象的话,在set里面不能重复。需要重写equals,但是不重写hashCode可能会分配到不同的Entry,这样两个对象同时会被存储。所以保证equals为真的同时hashCode也要相同。