复盘面经(2):快手Java日常实习一面 @小摆不算摆
作者:小摆不算摆链接:https://www.nowcoder.com/feed/main/detail/d74d31c882ca4f5691098d0ca8a0e24e来源:牛客网
1、自我介绍
2、仿大众点评中登录注册功能,介绍一下。
这里我回答一下我自己的项目htuoj里面的登陆注册功能。面试官您可以点击我建立上的这个项目的地址,我这个已经上线了,您可以点开右上角的登录注册就进入登录页面了。
首先是登录功能,登录功能需要输入学号和密码,然后直接点击登录,这里前端会校验学号或者密码是否输入,如果为空会给提示,正确输入之后。请求发送到后端,具体的逻辑是这样的,在这个密码在数据库中是加密存储的,所以需要将密码进行加密,然后到数据库中查询是否有该用户,如果在没有查询到该用户直接返回错误提示信息给前端,如果查询到该用户,就将生成一个token返回给前端,前端将token保存在本地,以后每次发起请求前带上这个token,这样就会知道发起请求的用户信息了。它是怎么知道的呢?我使用UUID来生成token,保证全局唯一。我将它存入redis缓存当中,用的是String数据结构,它的key是token,value是用户ID。这样在以后每次发起请求的时候,需要经过一层过滤器,这个过滤器会从redis缓存中检查用户的token是否存在,如果存在就可以获取用户ID,然后将这个用户ID放入ThreadLocal当中,ThreadLocal 是用来存储当前线程的私有信息,而且线程之间的是隔离的,所以不会有其他线程访问到当前线程的私有信息。在后续的业务执行过程中,都可以直接通过ThreadLocal获取用户信息。
我还想说一下我为啥要用redis来存token,为啥不直接用jwt。
jwt就是直接将用户信息和密钥等一起加密生成了一个token字符串,以后每次请求的时候后端可以直接根据密钥来解析jwt从而获取用户信息。这样当然很方便,但是也有缺点,如果有其他人拿到这个token恶意攻击网站,后端却不能主动让这个token失效。而且如果想实现一个账号只能在一台设备上使用,使用jwt没办法做到验证token的唯一。
redis存token就解决了这两个问题,首先它可以主动将token从缓存中删除让它失效,这样在想使用的时候只能重新登录。防止恶意攻击。第二就是它的token是唯一的,每一个账号可以只有一个token,只要有人登录后就会覆盖之前的token,就可以实现一个账号只能在一台设备上使用。
接下来就是注册功能:我这里注册需要输入用户名、学号、密码和邮箱来做用户注册。用户输入完用户码、学号、密码之后,需要点击获取验证码,这里前端校验用户输入格式是否正确。这里会发起一个获取验证码的请求,后端业务逻辑是这样的,首先检验用户名和学号是否已经存在了,我这里设置的是用户名和学号都必须是唯一的,我这里并没有直接去数据库中查询是否存在,而是使用布隆过滤器来检验用户名和学号是否存在,布隆过滤器可以快速的判断某个元素是否在集合中已经存在了,但是布隆过滤器存在误判,有个数据可能不存在,但判断存在了。那为啥还要使用它呢?首先如果这个项目的用户量很大,每天都有很多人来注册,如果每次都去数据库中去检查的话肯定会给数据带来巨大的压力,而且导致整个系统的性能下降。并且有可能存在有人恶意注册。所以可以使用布隆过滤器来检查,布隆过滤器是在内存中的,查询效率很高,误判率其实也很小,影响不大。而且最后注册的时候还有数据库的唯一索引来做最后的兜底。所以总的来说,使用布隆过滤器是一个很好的选择。
如果布隆过滤器判断已经存在就返回提示信息给前端。最后还需要检查邮箱是否出现过,因为我要保证一个邮箱只能注册一个账号,这个邮箱用来接受验证码,还可以用来找回密码,还可以发布活动信息到用户邮箱中。 但因为邮箱要用来发验证码,所以我一定要保证唯一,所以就不能使用过滤器了,而是直接去数据库中判断,判断不存在才能给该邮箱发送验证码,并且该验证码会保存到redis缓存中,有效期是60s。用户收到验证码再去注册的时候,就会校验验证码是否正确,如果正确还会进行校验用户名、密码是否存在过,因为在发送验证码到注册这一段时间可能已经被注册过了,所以我在校验一次,然后等到全部校验完成,才能去数据库中添加用户记录信息。校验完成之后,现在应该就可以直接住保存新用户信息到数据库表中了,但是我又想了一下,如果有人在某个时刻大量发起注册请求,那么这些都会去数据库中新增用户,但是只能有一个注册成功,其他全部失败。那么这样的话肯定会带来性能,所以为了解决这个问题我加了redis的分布式锁,锁的的用户名+学号,这样就保证相同的注册线程只能有一个线程拿到锁去注册,注册完成之后去将这些信息加入到布隆过滤器中,与此同时,其他的线程都需要等待,等他们中的某个线程拿到锁之后,再次去布隆过滤器中检查,如果存在的话就返回失败。这样就解决了这些问题。
3、双层拦截有哪些作用,或者说能防止哪些攻击?
刷新token有效期,并且很多请求是不需要登录就可以查看的,要将需要登录和不需要登录的请求分开。
4、ThreadLocal使用的场景和ThreadLocal的原理。
ThreadLocal经常用来保存用户信息,在使用的过程中可以直接获取到,并且不会被其他线程访问到。
ThreadLocal底层使用的是当前线程的ThreadLocalMap变量,它的key是ThreadLocal对象,value是存的值。
我们使用get方法时,首先会获取当前线程,然后再从当前线程中获取ThreadLocalMap变量,然后根据当前ThreadLocal对象获取对应的value。set方法方法同理。这样设计就可以保证线程的私有属性不会被其他线程访问到。
THreadLocal是用来解决线程安全问题的。可以让每个线程都有自己独立的变量副本,从而避免了线程间的资源共享和同步问题。
5、ThreadLocal会产生什么问题吗?
内存泄漏问题,内存泄漏是指内存的堆空间中由于对象一直增加,并且很多对象没有被回收,导致对象过多超出内存空间大小造成内存泄漏。
ThreadLocal的内存泄漏问题根本原因在于它的底层实现结构,每一个线程都有自己的ThreadLocalMap对象,ThreadLocalMap对象的key是ThreadLocal对象实例,value是他的值。
假如现在ThreadLocalMap中的key使用的是强引用,那么如果ThreadLocal对象实例不在被引用了,那么它也不能被垃圾回收,因为是强引用,所以可能会导致内存泄漏。而ThreadLocalMap的设计是弱引用,那么ThreadLocal对象就可以被回收了,但是它的value还是强引用,所以就导致这个键值对Entry不能被垃圾回收,这样还是可能会导致内存泄漏。为什么value要强引用呢,假如说我这个key的ThreadLocal对象还在继续使用,但是它的Value却被垃圾回收的,那数据不就丢失了吗,所以它是强引用。
所以发生内存泄漏的主要问题在于键值对没有被回收,并且当前线程还在继续执行。
如何解决呢?使用完ThreadLocal的时候一定要执行remover,也就是删除那个键值对Entry,这样就避免内存泄漏,而且ThreadLocal内部在执行set/get方法时,会探测key是否已经为null,如果为null就可以对这个键值对Entry进行垃圾回收。
6、子进程能访问父进程ThreadLocal的值吗?为什么?
不可以吧,他是根据当前线程来获取的,没法根据父进程获取吧。
在 Java 中,默认情况下子进程不能访问父进程 ThreadLocal
的值。下面为你详细解释其原因,并介绍一种可以实现子进程访问父进程相关值的方式。
原因分析
ThreadLocal
的工作原理
ThreadLocal
是 Java 中一个用于线程局部变量的工具类,它为每个使用该变量的线程都提供一个独立的变量副本,每个线程都可以独立地改变自己的副本,而不会影响其他线程所对应的副本。ThreadLocal
的数据是存储在每个线程自身的 ThreadLocalMap
中的。每个 Thread
对象都有一个 ThreadLocalMap
类型的成员变量 threadLocals
,当调用 ThreadLocal
的 set
方法时,实际上是将值存储到当前线程的 ThreadLocalMap
中;调用 get
方法时,也是从当前线程的 ThreadLocalMap
中获取值。
父子进程与线程独立性
在 Java 中,当创建一个新的子线程时,子线程是一个独立的执行单元,它有自己独立的 Thread
对象和 ThreadLocalMap
。父线程和子线程的 ThreadLocalMap
是相互独立的,父线程 ThreadLocalMap
中存储的数据并不会自动复制到子线程的 ThreadLocalMap
中。因此,子线程无法直接访问父线程 ThreadLocal
中的值。
示例代码验证
public class ThreadLocalExample { private static final ThreadLocal<String> threadLocal = new ThreadLocal<>(); public static void main(String[] args) { // 在父线程中设置 ThreadLocal 的值 threadLocal.set("Value from parent thread"); // 创建子线程 Thread childThread = new Thread(() -> { // 尝试在子线程中获取 ThreadLocal 的值 String value = threadLocal.get(); System.out.println("Value in child thread: " + value); }); childThread.start(); try { childThread.join(); } catch (InterruptedException e) { e.printStackTrace(); } // 在父线程中获取 ThreadLocal 的值 String parentValue = threadLocal.get(); System.out.println("Value in parent thread: " + parentValue); // 移除 ThreadLocal 的值 threadLocal.remove(); } }
在上述代码中,在父线程中设置了 ThreadLocal
的值,然后创建了一个子线程,在子线程中尝试获取该值。运行代码后会发现,子线程获取到的值为 null
,说明子线程无法直接访问父线程 ThreadLocal
中的值。
解决方案:使用 InheritableThreadLocal
如果需要子线程能够访问父线程 ThreadLocal
的值,可以使用 InheritableThreadLocal
类。InheritableThreadLocal
是 ThreadLocal
的子类,它允许子线程继承父线程中 InheritableThreadLocal
变量的初始值。当创建子线程时,会将父线程的 InheritableThreadLocal
变量的值复制到子线程的 inheritableThreadLocals
中。
示例代码
public class InheritableThreadLocalExample { private static final InheritableThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>(); public static void main(String[] args) { // 在父线程中设置 InheritableThreadLocal 的值 inheritableThreadLocal.set("Value from parent thread"); // 创建子线程 Thread childThread = new Thread(() -> { // 在子线程中获取 InheritableThreadLocal 的值 String value = inheritableThreadLocal.get(); System.out.println("Value in child thread: " + value); }); childThread.start(); try { childThread.join(); } catch (InterruptedException e) { e.printStackTrace(); } // 在父线程中获取 InheritableThreadLocal 的值 String parentValue = inheritableThreadLocal.get(); System.out.println("Value in parent thread: " + parentValue); // 移除 InheritableThreadLocal 的值 inheritableThreadLocal.remove(); } }
在上述代码中,使用 InheritableThreadLocal
替代 ThreadLocal
,子线程就可以获取到父线程中设置的值。
7、介绍一下缓存穿透、缓存雪崩、缓存击穿和你的解决方案。
这个太熟悉了,就不说了。
8、使用空对象解决了缓存穿透问题,如果此时再数据库中新增了该空对象,也就是说他现在不是一个空对象了,这个时候怎么办。
缓存一致性问题,先更新数据库,在删除缓存。这样下次在访问该数据时发现缓存失效,就直接去数据库查询并创建缓存。
9、缓存雪崩除了使用随机TTL还有没有其他的解决方案。(分为事前事中事后)
互斥锁和后台更新缓存。
事前:缓存预热
事中:互斥锁事后:后台更新缓存,有一个线程定时更新缓存,并且频繁检查缓存是否失效,如果失效主动去更新。
还有一种是如果请求发现缓存失效,就发送一条消息到消息队列中,有一个专门线程来处理消息队列中的更新缓存任务,处理的时候都会先检查缓存是否已经存在了,如果没存在就去构建缓存。
10、redis的i/o复用有了解吗?使用哪种机制来避免(epoll)。
这个没学过
11、Java基本数据类型有哪些?
int、byte、short、long、double、float、boolean、char
12、Java的三大特性?多态是怎么体现的?
封装:每个对象都有自己的属性和方法,那么可以将这些属性和方法封装在面向对象内部,这样外部想访问对象内部的属性和方法就只能通过对象暴露的方法。
继承:每个对象之间都有相似的属性或者方法,一个对象可以继承另外一个对象的公共属性和方法,这样就可以减少编码以及更明确对象之间的关系。
多态:继承同一个对象的多个子对象,子对象都重写父对象的方法,如果我想调用各个子对象的方法,只能通过对象实力调用,有了多态我就可以写一个方法,这个方法的参数是父对象,我调用这个方法,参数可以填子对象实例,子对象会直接转成父对象,调用方法的时候可以直接调用子对象重写的方法。
面向对象编程(Object-Oriented Programming,OOP)有三大核心特性,分别是封装、继承和多态。这三大特性相互配合,使得程序具有更好的可维护性、可扩展性和可复用性,下面为你详细介绍:
封装(Encapsulation)
- 定义:封装是指将数据(属性)和操作数据的方法(行为)捆绑在一起,形成一个独立的单元,也就是对象。同时,对对象的内部实现细节进行隐藏,只对外提供必要的访问接口。这样可以保护数据不被外部随意访问和修改,提高数据的安全性。
- 作用数据保护:防止外部代码直接访问和修改对象的内部数据,避免数据被意外修改而导致的错误。简化接口:对外只暴露必要的方法,使得外部代码调用更加简单,降低了代码的耦合度。
- 示例(Java)
// 定义一个类表示学生 class Student { // 私有属性,外部无法直接访问 private String name; private int age; // 提供公共的 getter 和 setter 方法来访问和修改属性 public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { if (age > 0) { this.age = age; } else { System.out.println("年龄不能为负数"); } } } public class EncapsulationExample { public static void main(String[] args) { Student student = new Student(); student.setName("张三"); student.setAge(20); System.out.println("学生姓名:" + student.getName() + ",年龄:" + student.getAge()); } }
在上述示例中,Student
类的 name
和 age
属性被声明为私有,外部代码无法直接访问。通过提供公共的 getter
和 setter
方法,可以对属性进行安全的访问和修改。
继承(Inheritance)
- 定义:继承是指一个类(子类)可以继承另一个类(父类)的属性和方法,从而实现代码的复用。子类可以在父类的基础上进行扩展,添加新的属性和方法,或者重写父类的方法以实现不同的行为。
- 作用代码复用:避免重复编写相同的代码,提高开发效率。建立层次结构:可以通过继承建立类之间的层次关系,使得代码结构更加清晰。
- 示例(Java)
// 定义一个父类:动物 class Animal { public void eat() { System.out.println("动物吃东西"); } } // 定义一个子类:猫,继承自动物类 class Cat extends Animal { public void meow() { System.out.println("猫喵喵叫"); } } public class InheritanceExample { public static void main(String[] args) { Cat cat = new Cat(); cat.eat(); // 调用父类的方法 cat.meow(); // 调用子类自己的方法 } }
在上述示例中,Cat
类继承了 Animal
类的 eat()
方法,同时还添加了自己的 meow()
方法。
多态(Polymorphism)
- 定义:多态是指同一个方法调用可以根据对象的不同类型而表现出不同的行为。多态主要通过继承和方法重写来实现,父类的引用可以指向子类的对象,并且在运行时根据实际对象的类型来调用相应的方法。
- 作用提高代码的灵活性:可以编写通用的代码,处理不同类型的对象,而不需要为每个具体类型编写特定的代码。可扩展性:当需要添加新的子类时,不需要修改现有的代码,只需要在子类中重写相应的方法即可。
- 示例(Java)
// 定义一个父类:形状 class Shape { public void draw() { System.out.println("绘制形状"); } } // 定义一个子类:圆形,继承自形状类 class Circle extends Shape { @Override public void draw() { System.out.println("绘制圆形"); } } // 定义一个子类:矩形,继承自形状类 class Rectangle extends Shape { @Override public void draw() { System.out.println("绘制矩形"); } } public class PolymorphismExample { public static void main(String[] args) { Shape shape1 = new Circle(); Shape shape2 = new Rectangle(); shape1.draw(); // 调用圆形的 draw 方法 shape2.draw(); // 调用矩形的 draw 方法 } }
在上述示例中,Shape
类的引用 shape1
和 shape2
分别指向 Circle
和 Rectangle
对象,调用 draw()
方法时,会根据实际对象的类型调用相应子类的 draw()
方法。
综上所述,封装、继承和多态是面向对象编程的三大特性,它们相互协作,共同构建了高效、灵活和可维护的软件系统。
多态是面向对象编程中一个非常重要的特性,它允许不同类的对象对同一消息做出不同的响应。多态主要通过以下几种方式体现:
方法重载(编译时多态)
- 定义:方法重载是指在一个类中定义多个具有相同名称但参数列表不同的方法。编译器会根据调用方法时传递的参数类型和数量来决定调用哪个具体的方法,这种多态性在编译时就可以确定,因此也称为编译时多态。
- 示例(Java)
class Calculator { // 加法方法,两个整数相加 public int add(int a, int b) { return a + b; } // 加法方法,三个整数相加 public int add(int a, int b, int c) { return a + b + c; } // 加法方法,两个双精度浮点数相加 public double add(double a, double b) { return a + b; } } public class MethodOverloadingExample { public static void main(String[] args) { Calculator calculator = new Calculator(); int result1 = calculator.add(1, 2); int result2 = calculator.add(1, 2, 3); double result3 = calculator.add(1.5, 2.5); System.out.println("两个整数相加结果:" + result1); System.out.println("三个整数相加结果:" + result2); System.out.println("两个双精度浮点数相加结果:" + result3); } }
在上述示例中,Calculator
类中定义了三个名为 add
的方法,但它们的参数列表不同。在调用 add
方法时,编译器会根据传递的参数类型和数量来选择合适的方法。
方法重写与向上转型(运行时多态)
- 定义:方法重写是指子类重新定义父类中已经存在的方法,要求方法名、参数列表和返回值类型都相同。向上转型是指将子类对象赋值给父类引用。当通过父类引用调用重写的方法时,实际执行的是子类中的方法,这种多态性在运行时才能确定,因此也称为运行时多态。
- 示例(Java)
// 定义一个父类:动物 class Animal { public void makeSound() { System.out.println("动物发出声音"); } } // 定义一个子类:猫,继承自动物类 class Cat extends Animal { @Override public void makeSound() { System.out.println("猫喵喵叫"); } } // 定义一个子类:狗,继承自动物类 class Dog extends Animal { @Override public void makeSound() { System.out.println("狗汪汪叫"); } } public class MethodOverridingExample { public static void main(String[] args) { // 向上转型 Animal cat = new Cat(); Animal dog = new Dog(); // 调用重写的方法 cat.makeSound(); dog.makeSound(); } }
在上述示例中,Cat
和 Dog
类都重写了 Animal
类的 makeSound
方法。通过将 Cat
和 Dog
对象分别赋值给 Animal
类型的引用,在调用 makeSound
方法时,实际执行的是子类中重写的方法,这体现了运行时多态。
接口与实现类(运行时多态)
- 定义:接口是一种抽象类型,它只定义方法的签名,而不包含方法的实现。类可以实现一个或多个接口,并实现接口中定义的方法。通过接口引用指向实现类的对象,当调用接口方法时,会根据实际对象的类型调用相应实现类的方法,这也是运行时多态的一种体现。
- 示例(Java)
// 定义一个接口:可移动 interface Movable { void move(); } // 定义一个实现类:汽车,实现可移动接口 class Car implements Movable { @Override public void move() { System.out.println("汽车在行驶"); } } // 定义一个实现类:飞机,实现可移动接口 class Plane implements Movable { @Override public void move() { System.out.println("飞机在飞行"); } } public class InterfaceExample { public static void main(String[] args) { // 接口引用指向实现类对象 Movable car = new Car(); Movable plane = new Plane(); // 调用接口方法 car.move(); plane.move(); } }
在上述示例中,Car
和 Plane
类都实现了 Movable
接口的 move
方法。通过将 Car
和 Plane
对象分别赋值给 Movable
类型的引用,在调用 move
方法时,会根据实际对象的类型调用相应实现类的 move
方法,体现了运行时多态。
综上所述,多态通过方法重载、方法重写与向上转型以及接口与实现类等方式体现,它使得代码更加灵活、可扩展和可维护。
13、介绍一下HashMap。(扩容机制、寻址、为什么扩容是二倍)。
HashMap是Java比较常用的集合,它是一种双列集合,它存的是键值对类型的元素。它存的键值队中的key是唯一的,value不是唯一的,它可以很快的根据关键字key来获取对应的value。这得益于它底层的数据结构。在jdk1.7之前,hashmap底层使用的是数组+链表的形式,当hashMap新增一个键值对的时候,首先会使用一个哈希函数将该键值队的key进行hash得到它在数组存放的下标,然后将该元素存放到该下标位置上。这样的话在想获取该元素的时候,直接根据key经过哈希函数得到下标获取对应的value,时间复杂度是O(1),但是可能会存在不同的key经过哈希函数得到了相同的下标,也就是哈希冲突问题,它会讲相同下标的所有元素通过链表的形式连接起来,这样就解决了哈希冲突问题。但是如果该链表过长的话,那么查询某个元素的时间复杂度是O(n),为了解决这一问题,在jdk1.8对此做了改进,hashMap底层数据结构改成了数组+链表+红黑树。当哈希冲突不是特别严重时,和jdk1.7是一样的,但如果某个下标的链表过长的话,就会将该链表转换成红黑树,红黑树是一种自平衡的二叉树,它查询某个元素的时间复杂度是logn。所以就很好的解决了这一问题。接下来主要说jdk1.8版本的hashMap的一些主要方法,比如get方法和put方法首先说一下get方法,首先通过哈希函数得到该key的下标,然后检查该下标的节点的key值是否和要获取的key值相同,如果相同,那么直接返回该节点。如果不同,那么证明该下标存放的不只是一个节点,应该是一个链表或者红黑树。检查该头节点是否是链表节点,如果是链表节点,那么就会根据next指针遍历链表直到找到相同的key。如果是红黑树节点,我们知道红黑树节点是自平衡的二叉树,它会讲每个节点的key计算得到一个权重值,根据这个权重值在红黑树中进行排序,红黑树中的每个节点的左子树都小于当前节点,每个节点的右子树都大于当前节点,要在红黑树中查询某个键值队,首先要将它的key与根节点进行比较,小于的话就在左子树中进行查找,大于就在右子树进行查找,直到找到该元素。还有就是如果在查询结束之后都没有找到该key,那么直接返回null。在来说一下put方法:put方法主要是两件事,扩容和链表转成红黑树。第一步:先检查数组是否初始化,如果没有先进行初始化,初始化的容量是16,负载因子是0.75。第二步:查找要添加的键值队的key是否在hashMap中出现过,查找某个元素是否在hashMap中出现过在刚才的get方法已经说过了,具体查找步骤是一样。如果找到这个元素,那么就将原键值队的value替换成新加入键值队的value。这个方法就结束。如果没有找到这个元素,就需要新加入一个键值队了,这里就有三种情况。
- 如果新加入键值队的key得到的下标为null的话,说明还没有元素添加进来,直接赋值到该下标就行了。
- 如果下标是一个链表的话,那么就将新的键值队加入到链表的尾部。如果加入完之后,链表的长度大于等于8了,那么就需要将该链表转化成红黑树,转换成红黑树之前需要检查当前数组的长度是否大于等于64,如果没有就先扩容一次,扩容是直接将数组大小翻倍,比如一开始是16,扩容后就是32。这里扩容后直接返回了,不会在链表转换成红黑树,只有当下次又发现链表长度大于等于8,并且数组长度大于等于64的时候才会转换成红黑树,所以需要经过两次扩容后,才会将链表转换成红黑树。转换成红黑树的过程就是将原来的链表的每个节点转成红黑树节点并且一个个加入到红黑树中,红黑树会自平衡,最后就得到了一个红黑树。
- 如果下标是一个红黑树节点的话,那么就会根据key计算的权重值将新节点插入红黑树中。 新节点加入完之后,就检查当前集合中的元素个数是否达到阈值,如果到达阈值,就需要扩容。扩容是直接将数组大小翻倍。
为啥是扩容是2倍?
- 位运算,计算效率高。
- 2倍是一个比较好的扩容大小,能够很好的减少哈希冲突。重新计算元素位置时,原来的元素要么在原位置要么在原位置+旧数组大小的位置。很多元素不会移动。
14、HashMap中怎样解决hash冲突。
链表+红黑树
15、HashMap中默认的负载因子(加载因子)
0.75
16、JVM的结构
程序计数器:每个线程都有一个程序计数器,用来记录下一条指令的地址。
虚拟机栈:虚拟机栈是用来支持方法调用的,线程调用一个方法时,就会创建一个栈帧加入到虚拟机栈中,栈帧包括了方法的局部变量和操作数等信息。当方法执行完,这个栈帧也会从虚拟机栈顶弹出。
本地方法栈:本地方法栈跟虚拟机栈类似,区别就是它调用的方法都是本地方法,也就是用natice关键字修饰的方法,这些方法不是由Java语言编写,而是由C/C++编写。
堆:用来存储所有的对象实例,所有new创建的对象都会加入到堆中,而且堆中所有的对象是共享的。
方法区:方法区用来存储类信息、方法信息、字段信息、静态变量、常量等。
直接内存:直接内存并不是JVM堆内存的一部分,俄式属于操作系统的内存空间,直接手操作系统管理。它允许Java程序直接分配和使用操作系统的物理内存,而不是通过JVM堆进行间接操作。这样可以提高IO操作的性能。
接下来我想从一个java源文件被加载并执行的全过程来理解JVM。之前学的时候一直没有把这些知识串起来,这里我想做一个总结。
public class MemoryExample {
public static void main(String[] args) {
// 堆内存使用:创建对象
Object obj = new Object();
// 栈内存使用:定义局部变量
int num = 10;
}
}
我将以这个类来详细的讲述JVM。
首先我们知道一个java语言编写的源文件(.java文件)经过遍以后会变成字节码指令文件(.class)。这个字节码指令文件就可以被JVM加载和执行。那JVM是怎么加载的呢?
JVM加载字节码文件包括加载、连接、初始化这三个阶段。
1. 加载
1. 查找并加载字节码文件:类加载器会根据类的全名去查找并读取对应的字节码文件。类加载器会按照双亲委派模型去加载。
1. 双亲委派模型:当一个类加载器收到类加载请求的时候,它首先不会自己去尝试加载这个类,而是把请求委派给父类加载器去完成。只有当父类加载器无法完成该加载请求时,子加载器才会尝试自己去加载。
2. 生成Class对象:将字节码文件中的二进制数据加载到内存中,方便后续连接和验证,并将其转换为了一个Class对象,这个对象会存到JVM内存结构中的方法区中,在jdk8之前,存的都是方法区的永久代中,而jdk8之后,存放的都是元空间。以后程序中使用该类时,都通过这个Class对象进行操作。
2. 连接
1. 验证
1. 确保字节码文件的正确性:验证阶段主要是为了确保加载的字节码文件符合JVM的规范,不会危害JVM的安全。验证内容包括文件格式验证(检查字节码文件的魔数、版本号等是否正确)、元数据验证(检查类的继承关系、方法的访问权限等是否合法)、字节码验证(检查字节码指令的语义和逻辑是否正确)和符号引用验证(检查符号引用是否能正确解析为直接引用)。
2. 准备
1. 为静态变量分配内存并设置初始值:在准备阶段,JVM会为类的静态变量分配内存,并将其初始化为默认值。静态变量是存放在方法区中的。例如,对于int类型的静态变量,初始值为0.对于引用类型的变量,初始值为null。这里只是默认值,而不是代码中显示赋予的值。
3. 解析
1. 将符号引用转化为直接引用:符号引用放在方法区的运行时常量池中,符号引用是逻辑引用,用字符串来表示的引用,指向被引用的类、字段或方法。解析阶段会讲这些符号引用转换为直接引用,即内存的实际地址。这样,JVM在运行时就可以直接通过直接引用访问到对应的类、字段或方法。
3. 初始化
1. 执行类的初始化代码:初始化阶段主要是执行类的静态代码块和静态变量的显式赋值操作。在这阶段,静态变量会赋予代码中显示制定的值,静态代码块也会按照顺序执行。
为什么要有双亲委派模型,为什么要有多个类加载器,只有一个不好吗?
为什么要有多个类加载器?
1. 隔离类的加载范围:不同的类加载器负责加载不同路径下的类,这样可以将java核心类库、扩展类库和用户自定义类进行隔离。这样分工可以使得不同来源的类可以在不同的类加载器管理下,避免相互干扰。例如,自己定义的类不会影响到Java核心类库的加载和运行。
启动类加载器(Bootstrap ClassLoader):负责加载 Java 的核心类库,如 java.lang、java.util 等包下的类。这些类是 Java 运行的基础,被放置在 JDK 安装目录下的 jre/lib 目录中。
扩展类加载器(Extension ClassLoader):加载 JDK 扩展目录 jre/lib/ext 下的类库。这些类是对 Java 核心功能的扩展,通过扩展类加载器加载可以与核心类库区分开来。
应用程序类加载器(Application ClassLoader):加载用户类路径(classpath)下的类,也就是开发者自己编写的 Java 类和引入的第三方类库。
为什么会影响到Java核心类库的加载和执行,我能自定义java核心类库里面的类吗?
答案是可以的,我可以自己编写一个java核心类库的类,原来这个包名可以随便写的,但是如果想要编译这个类就会报错:
错误: 程序包已存在于另一个模块中: java.base
package java.lang;
^
1 个错误
所以就需要多个类加载器,不然就会发生冲突了,要保证程序运行时只能有一个类加载,不能有重名的。
package java.lang;
public class String {
public String() {
System.out.println("This is a custom String class.");
}
}
1. 为什么要有双亲委派模型?
1. 知道了JVM为啥有不同的加载器来加载不同的文件,这里就好理解为啥有双亲委派模型了。如果当前的应用程序中定义了和java核心类库中相同的类,那么只能加载一个,加载谁呢,JVM默认加载的肯定是加载核心类库中的类,肯定不会让自定义的类去影响java核心类库的类,那样就不安全了。有了双亲委派模型,那么就可以保证用户自定义的类不会覆盖java核心类库中的类。而且还可以避免类的重复加载。如果一个类加载器已经加载过某个类了,其他类加载器也想加载这个类的时候可以直接从父类加载器中获取,而不用在自己加载了。
永久代和元空间的区别是什么?
1. 永久代:
1. 内存位置:永久代是JVM堆的一部分,和Java堆的新生代、老年代等区域一样,都受JVM堆内存大小的限制。可以通过参数设置永久代的最大大小。
2. 存储内容:存储类的元数据信息(类的结构、字段、方法等)、运行时常量池(包含字符串常量池等)以及类的静态变量。
2. 元空间
1. 内存位置:元空间使用的是本地内存,不占用JVM堆内存。这样元空间的大小只受操作系统可用内存的限制,不会因为JVM堆内存不足而受到影响。
2. 存储内容“同样存储类的元数据信息、运行时常量池等。但从JDK7的时候,字符串常量池从永久代移到了Java堆中,JDK8之后都保留这种设置。
优缺点对比:
1. 永久代的缺点:
1. 内存溢出:用于永久代的大小是固定的,当加载的类过多或者类的元数据信息过大时,容易导致永久代内存溢出。
2. 调优困难:永久代的大小需要手动设置合适的参数,如果设置不当,可能会导致内存浪费或者频繁的垃圾回收。
2. 元空间的优点:
1. 避免内存溢出:使用内地内存,不受JVM堆内存的限制,大大降低了因类元数据信息过多而导致的内存溢出风险。
2. 垃圾回收更高效:元空间的垃圾回收机制更加灵活,当类不再使用时,元空间可以及时回收其占用的内存,减少了Full GC的频率。
运行时常量池都保存了什么?
1. 字面量:如字符串常量、基本数据类型的常量值等。例如,代码中的"hello"字符串常量会存储在运行时常量池中。
2. 符号引用:包括类和借口的全限定名、字段的名称和描述符、方法的名称和描述符等。这些符号引用在类加载的解析阶段会被转换为直接引用。
main方法执行过程
1. 程序计数器
1. 程序计数器记录了当前线程正在执行的字节码指令的地址。在执行main方法的过程中,它会不断更新以提示下一条要执行的指令,保证线程能够正确地顺序执行或者进行分支跳转能操作。如果main方法正在执行int a = 1这行代码,那么程序计数器就会指向与该操作对应的字节码指令位置。
2. 虚拟机栈
1. 每个线程都有自己独立的虚拟机栈,当main方法被调用时,会在栈中为其创建一个栈帧。栈帧包含以下内容:
1. 局部变量表:存放方法中的局部变量,在main方法中,obj和num作为局部变量会存储在这里。obj存储的是堆中Object对象的引用地址,num直接存储值。
2. 操作数栈:用于执行字节码指令时的操作数存储和计算。比如int c = a + b,会先讲a和b的值压入操作数栈中,然后在讲他俩取出相加,再将相加的结果压入栈中,最后将结果赋值给c。
3. 动态链接:保存对运行时常量池中该方法的引用,在运行时可以动态解析方法调用。比如父类方法被子类重写,那么调用这个方法的时候会根据对象的实际类型选择调用方法。
4. 方法出口:记录方法执行完毕后返回的位置等信息,以便在方法执行结束后能正确的返回到调用处继续执行后续代码。
3. 本地方法栈
1. 本地方法栈和虚拟机栈类似,但是本地方法栈只针对本地方法的调用,本地方法是指使用native关键字修饰的方法。这些方法是用C/C++方法写的,是直接与操作系统交互的方法。比如CAS方法。
4. 堆
1. 堆用来存储所有的对象实例。Object obj = new Object()这行代码创建的Object对象实例会存储在堆中。堆是所有线程共享的内存区域,用来存储对象实例和数组等。这个Object对象包含了对象头、实例中的字段信息等。
JVM的内存结构已经类加载都讲完了。
JVM的内存结构主要分为以下几个部分:
- 程序计数器(Program Counter Register)作用:是一块较小的内存空间,可看作是当前线程所执行的字节码的行号指示器。在JVM的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。特点:每个线程都有一个独立的程序计数器,这是线程私有的内存区域,生命周期与线程相同。如果线程执行的是Java方法,计数器记录的是正在执行的虚拟机字节码指令的地址;如果执行的是Native方法,计数器值则为空(Undefined)。
- Java虚拟机栈(Java Virtual Machine Stack)作用:与线程紧密相关,描述的是Java方法执行的内存模型。每个方法在执行时都会创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法从调用直至执行完成的过程,对应着一个栈帧在虚拟机栈中入栈到出栈的过程。特点:同样是线程私有的,生命周期与线程一致。局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。异常情况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存,则抛出OutOfMemoryError异常。
- 本地方法栈(Native Method Stack)作用:与Java虚拟机栈类似,只不过它是为JVM使用到的Native方法服务的。Native方法是用Java以外的语言(如C、C++)编写的,用于直接访问操作系统资源或实现一些Java无法实现的功能。特点:线程私有的内存区域。其具体的实现方式和数据结构由不同的JVM厂商决定,有些JVM可能将本地方法栈和Java虚拟机栈合二为一。同样可能抛出StackOverflowError和OutOfMemoryError异常。
- Java堆(Java Heap)作用:是JVM所管理的内存中最大的一块,被所有线程共享。几乎所有的对象实例以及数组都在堆上分配内存。从内存回收的角度看,由于现在收集器基本都采用分代收集算法,所以Java堆还可以细分为新生代(Young Generation)和老年代(Old Generation);再细致一点,新生代又可分为Eden空间、From Survivor空间和To Survivor空间。特点:在JVM启动时创建,堆的大小可以通过参数 -Xms(初始堆大小)和 -Xmx(最大堆大小)来设置。如果在堆中没有足够的内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
- 方法区(Method Area)作用:也是被所有线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。在JDK 7及之前,习惯上把方法区称为“永久代”(Permanent Generation),但这只是JVM的一种实现方式,并不意味着方法区就等同于永久代。从JDK 8开始,移除了永久代,使用元空间(Meta Space)来实现方法区的功能,元空间使用的是本地内存。特点:方法区的大小不必是固定的,JVM可以根据应用的运行情况动态调整。当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
- 运行时常量池(Runtime Constant Pool)作用:是方法区的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容在类加载后存放到运行时常量池中。运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,运行期间也可能将新的常量放入池中,如String类的intern()方法。特点:运行时常量池在类加载后才会创建,不同的类有各自的运行时常量池。当常量池无法再申请到内存时会抛出OutOfMemoryError异常。
17、栈和堆的区别(空间大小、存储的内容、怎么存储(高向低还是低向高))
堆要比栈大,因为堆要存储所有对象实例,经常会堆内存使用完导致垃圾回收。而栈是只有调用方法的时候才使用,而且每个线程都有自己的栈空间,所以堆一般要比栈大。
存储的内容:堆存的是对象实例,栈存的是方法执行所需要的栈帧。
怎么存储:栈是高向低,堆是低向高。
在Java虚拟机(JVM)中,栈和堆是内存结构中的两个重要区域,它们在空间大小、存储内容及存储方向上存在明显区别:
1. 空间大小
- 栈:每个线程都有独立的Java虚拟机栈,其大小相对较小。栈的大小一般是在创建线程时就确定的,通常只有几百KB到几MB。例如,在常见的JVM默认配置下,一个线程的栈大小可能是1MB左右。栈的大小可以通过JVM参数-Xss进行调整,不过设置过大会导致系统可创建的线程数量减少,因为每个线程的栈都会占用一定的内存空间。
- 堆:堆是被所有线程共享的,是JVM内存中最大的一块区域。其大小通常远远大于单个线程的栈空间大小,在现代应用中,堆的大小可能从几十MB到数GB不等。堆的大小可以通过-Xms(初始堆大小)和-Xmx(最大堆大小)等JVM参数灵活调整,以适应不同应用程序对内存的需求。例如,通过设置-Xms256m -Xmx1024m,可以将堆的初始大小设置为256MB,最大扩展到1GB。
2. 存储的内容
- 栈:栈帧:主要存储栈帧,每个方法调用对应一个栈帧。栈帧包含局部变量表、操作数栈、动态链接和方法出口等信息。例如,当一个方法中有int a = 5;这样的局部变量声明时,变量a就存储在该方法对应的栈帧的局部变量表中。方法调用相关信息:在方法调用过程中,栈用于保存方法的返回地址、参数传递等信息。当方法A调用方法B时,方法A的当前执行状态(如局部变量值、程序计数器的值等)会被保存在栈中,以便方法B执行完毕后能够正确返回继续执行方法A。
- 堆:对象实例:几乎所有的对象实例以及数组都在堆上分配内存。例如,当执行String str = new String("Hello");语句时,new String("Hello")创建的字符串对象就存储在堆中。对象的成员变量:对象的成员变量也存储在堆中对象的内部。例如,对于一个自定义类class Person { int age; String name; },当创建Person对象Person p = new Person();时,age和name变量会随着Person对象一起存储在堆中。
3. 存储方向
- 栈:栈的存储方向是从高地址向低地址扩展。可以想象栈就像一个栈式结构的数据容器,新的栈帧会不断压入栈中,栈指针向下移动(指向低地址方向),栈帧出栈时栈指针向上移动(指向高地址方向)。这种从高地址向低地址的扩展方式与栈的后进先出(LIFO)特性相匹配。
- 堆:堆的存储方向通常是从低地址向高地址扩展。随着新对象的不断创建,堆内存从低地址开始逐渐向高地址方向分配空间。当堆空间不足时,垃圾回收机制会介入,回收不再使用的对象所占用的空间,以便为新对象分配内存。
18、B+树和B树的区别
从结构上,B树的每个节点既有索引又有数据,而B+树只有叶子结点存放数据,非叶子结点存放索引,并且叶子结点直接通过指针链接形成了一个双向链表。
在查询上,B树查询的过程中每次都需要将整个节点中的索引和数据都从磁盘读取到内存当中来,而大部分情况下读取的节点中的数据都是不需要的,由于节点中存了数据导致整个节点占据的空间很大,磁盘读取的过程中花费更多的时间,而且也需要更多的内存空间来存放这些数据,所以就导致查询性能不好。而且B树如果要执行范围查询的话,就需要执行中序查询,这样就会查询到更多的节点,就查询更多无用的数据,整体上的性能不好。
而B+由于它只有叶子结点存放数据,而非叶子结点存放索引,所以查询过程中读取到的每个节点都是有用的,并且节点占据的空间也很小。磁盘读取的时间也就少。所以它在查询上的性能是比B树要好很多的。而且B+树可以很好的支持范围查询,因为它的所有数据都在叶子结点,而且叶子结点直接通过指针链接形成双向链表,所以范围查询只需要查询到左边界的元素在哪个叶子结点就行,不需要进行中序遍历,直接通过指针找到它后边所有的数据,所以它的范围查询效率也很高。
而且在插入和删除上,b+树的效率也比B树高。B+树删除一个数据只需要删除某个叶子结点就行了,不需要复杂的平衡操作,比如左旋右旋,而B树由于数据放在每个节点上,所以可能需要复杂的平衡操作。插入同理。
19、从树高的层面来看,B树和B+树那个树更高。
B树更高,因为B树的每个节点都会存放数据,数据量多了就会分裂节点,所以需要更多的节点,所以树就更高。
手撕:迷宫问题,给一个二维数组内容为0和1,1代表墙壁,0代表通道。求从左上角开始到右下角的最短路劲。(力扣:490 mid)
DFS 或者BFS
问的不难,但是答的不好。