(一) java基础面试题

文章目录

    • 1.基础相关
      • 1.1 重载(overload)和重写(overried)的区别是什么?
      • 1.2 final,finally,finalize的区别是什么?
      • 1.3 接口和抽象类的区别是什么?
      • 1.4 String类中常用的方法有哪些?
      • 1.5 访问修饰符有哪些?
      • 1.6 ==和equals的区别是什么?
      • 1.7 Java类加载的过程是什么样的?
      • 1.8 你了解哪些类加载器?
      • 1.9 面向对象的特征有哪些?
      • 1.10 String,StringBuffer,StringBuilder的区别是什么?
      • 1.11 反射的作用是什么?怎么理解反射?
    • 2 jvm相关
      • 2.1 说说Jvm内存如何分配?
      • 2.2 说一下Jvm垃圾回收机制?
      • 2.3 Jvm内存如何调优?
      • 2.4 说说Jvm内存泄漏和内存溢出。
      • 2.5 如何判断对象是否可以被回收?
      • 2.6 Jvm中垃圾回收算法有哪些?
    • 3 异常相关
      • 3.1 异常的体系结构
      • 3.2 throw和throws的区别是什么
      • 3.3 异常的解决方式
    • 4 多线程和锁相关
      • 4.1 线程和进程的区别是什么?
      • 4.2 创建线程的方式有哪些?
      • 4.3 多线程之间的通信方式有哪些?
      • 4.4 线程的生命周期是什么?
      • 4.5 sleep方法和wait方法有什么区别?
      • 4.6 如何解决线程安全的问题?
      • 4.7 介绍一下你了解的线程池
      • 4.8 线程池的工作原理是什么样的?
      • 4.9 你对锁了解多少?
      • 4.10 什么是死锁?
      • 4.11 如何解决死锁?
      • 4.12 死锁产出的条件
      • 4.13 分布式锁实现的方式有哪些?
      • 4.14 项目中如何采用分布式锁
    • 5 集合相关
      • 5.1 java中的集合有哪些?
      • 5.2 HashMap底层原理
      • 5.3 ArrayList 和 Linkedlist 区别?
      • 5.4 concurrentHashMap的底层结构?
      • 5.5 hashMap 和 和 hashTable 的区别?
    • 6 javaweb相关
      • 6.1 说一下servlet的生命周期
      • 6.2 cookie 和 和 session 的区别?
      • 6.3 cookie 浏览器禁止后,session 还有效么?
    • 7 锁
      • 7.1 常用的锁
      • 7.2 分布式锁一般有三种实现方式:
      • 7.3 分布式锁需满足以下四个条件:

1.基础相关

1.1 重载(overload)和重写(overried)的区别是什么?

方法的重载和重写都是实现多态的方式,区别在于
重载:实现的是编译时的多态性,而重写实现的是运行时的多态性。
重载:发生在一个类中,方法声明相同、参数列表则视为重载,与返回值无关;
重写:发生在子类与父类之间,重写要求子类被重写方法与父类被重写方法声明完全相同。

1.2 final,finally,finalize的区别是什么?

final:修饰符,可以修饰变量,方法,类。final 修饰变量,变量成为常量,只能被赋值一次;final 修饰方法,则方法不能够被重写;final 修饰类,则类无法被继承。
finally:通常放在 try…catch…的后面构造总是执行代码块,这就意味着程序无论正常执行还是发生异常,这里的代码只要 JVM 不关闭都能执行,可以将释放外部资源的代码写在 finally 块中。
finalize:Object 类中定义的方法,Java 中允许使用 finalize()方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。这个方法是由垃圾收集器在销毁对象时调用的,通过重写 finalize()方法可以整理系统资源或者执行其他清理工作。

1.3 接口和抽象类的区别是什么?

相同点:都不能被实例化,都可以定义抽象方法
抽象类:本质就是类,只能被单继承,可以有构造方法,常量及变量,普通方法及构造方法。
接口:在1.7之前只能声明静态常量、抽象方法, 1.8及之后可以添加默认、静态方法。可以多实现,没有构造方法。

1.4 String类中常用的方法有哪些?

split():把字符串分割成字符串数组
equals(Object obj):比较字符串的内容是否相同
subString(int start):截取字符串
replace(char oldChar, char newChar):字符串的替换
charAt(int index): 获取指定位置的字符
indexOf(String str)/latIndexOf(String str):获取指定字符第一次|后者一次出现的索引位置
length():获取字符串长度
startwith(String prefix):判断字符串对象是否以指定字符开头
endwith(String suffix):判断字符串对象是否以指定字符串结尾
trim():去除字符串两端空格
contains(CharSequence s):查看字符串中是都含有指定字符
concat(String str):在原有的字符串的基础上加上指定字符串

1.5 访问修饰符有哪些?

public:是最为宽松的访问修饰符,用于修饰的成员可以被同一包中的所有类、其他包中的所有类和所有子类所访问。
protected:修饰符用于修饰类的成员,只有在同一包中的类或其他包中的子类可以访问它,其他类不能访问。
default:默认访问级别仅适用于同一包中的所有类。
private:是最为严格的访问修饰符,只有在同一类中才能访问该成员,其他类都无法访问

1.6 ==和equals的区别是什么?

==:在比较基本数据类型时比较的是值,比较两个对象时比较的是两个对象的地址值;
equals()方法存在于 Object 类中,默认效果和==号的效果一样,也是比较的地址值,Java 提供的所有类中,绝大多数类都重写了 equals()方法,重写后的 equals()方法一般都是比较两个对象的值 。

1.7 Java类加载的过程是什么样的?

类加载是JVM将.class文件加载到内存,并对数据进行校验、转换和初始化以形成可使用Java类的机制。包含三个过程
加载过程:使用类加载将.class加载到内存产出Class对象
链接过程:验证Class对象语法,准备内存空间,解析数据结构
初始化过程:对静态成员变量进行初始化操作。

1.8 你了解哪些类加载器?

根类加载器:JVM内核中的加载器,主要加载jdk安装目录下jre/lib下的系统资源库
扩展类加载器:主要加载jdk安装包下ext/扩展包下的资源库
应用程序类加载器:主要加载用户指定路径的资源,也就是用户编写的代码由它加载
自定义类加载器:自定创建一个类加载,有特殊需求才会使用

1.9 面向对象的特征有哪些?

面向对象有四大特点,封装,继承,多态,抽象
封装:就是把属于同一类事物的共性归到一个类中,以方便使用。 隐藏内部的细节,只保留一些对外接口
继承:子类对父类的属性与方法的接受,并加入子类特有的属性与方法。提高代码的复用性
多态:字面意思,就是多种形态,不同子类实现相同行为,具体的细节不同;
抽象:抽象是将一类对象的共同特征总结出来构造类的过程,包括数据抽象和行为抽象两方面

1.10 String,StringBuffer,StringBuilder的区别是什么?

三个都是处理字符串的类
从可变性来说:String使用final修饰,所以是不可变的,StringBuilder与 StringBuffer是可变的字符串
从安全上来说:String是不可变的,也就可以理解为常量,线程安全。StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是不线程安全的。
从性能上来说:每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的String 对象。StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。StirngBuilder的效率会高一些,而StringBuffer的底层加了同步的关键字,性能会有所下降

1.11 反射的作用是什么?怎么理解反射?

理解:简单说,在 Java 中的反射机制是指在运行状态中,对于任意一个类都能够知道这个类所有的属性和方法,并且对于任意一个对象,都能够调用它的任意一个方法;这种动态获取信息以及动态调用对象方法的功能就是Java 语言的反射机制。
作用:增加程序的灵活性,避免将程序写死到代码里,在运行期间可获取类中声明的信息,构建任意一对象,并调用对象的方法。可以调用私有的成员

2 jvm相关

2.1 说说Jvm内存如何分配?

JVM内存分为堆,Jvm栈,方法区,程序计数器,本地方法栈
方法区和堆是所有线程共享的内存区域;而JVM栈、本地方法栈和程序员计数器是运行是线程私有的内存区域。
堆:是虚拟机内存中最大的一块。在虚拟机启动时创建。创建对象开辟堆内存空间,存储对象的成员,空间只能被垃圾回收器回收,不会自动回收
Jvm栈:虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息
程序计数器:是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器
本地方法栈:与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务

2.2 说一下Jvm垃圾回收机制?

jvm 的垃圾回收机制后很多方法和垃圾回收器,我只了解分代管理法和清除标记法。
1.在jvm内存中有新生代、年老代、永生代,新生代中又包含 Eden和 survival,幸存者有两块内存区域,但是使用时,仅一个区域可用,另一块幸存者内存区必须为空。
2、当创建对象时,会首先在 Eden 中开辟新的空间,如果 Eden 的内存区域不够用,无法开辟新的内存空间,则会对 Eden 进行扫描,然后 标记。垃圾回收器此时会找出哪些内存在使用中,还有哪些不是。垃圾回收器要检查完所有的对象,知道哪些有被引用,哪些没有。 对需要清除的对象进行标记,清理垃圾对象,然后将保留的对象拷贝到 Survival幸存者区域。
3、如果 Eden 和 Survival 新生代内存区域全部存满,这时候对整个新生代内存区域进行扫描,将需要清除的对象进行标记,进行清除,将其他对象拷贝到年老代,这种 GC,称之为 YoungGC.
4、如果年老代内存区域也存满,需要对整个内存区域进行扫描,对对象进行标记,清除新生代和年老代内存区域,将垃圾对象清除,这种 GC,称之为 Full GC。
5、垃圾回收器分为串行回收器、并行回收器、并发回收器,串行垃圾回收器单线程,效率低,并行和并发回收器为多线程,但是并发回收器会造成程序阻塞,所以使用并行回收器进行垃圾回收,过程中会产生垃圾回收碎片,会自动转换为串行垃圾回收器,清理完碎片,自动转换为并行垃圾回收器,不会对程序造成影响。

2.3 Jvm内存如何调优?

之前看过一些,可以具体看是哪里需要调优,尽可能让对象都在新生代里分配和回收,可以调整堆里面的一些参数进行设置
如果是栈的深度不够可能出现的原因有递归调用同一时间执行大量的方法,资源耗尽方法中声明大量的局部变量xss配置太小
如果是堆内存溢出可能是因为堆内存配置小访问量飙升超过预期内存泄露
可以通过参数配置自动获取dump文件,分析大对象,堆中存储的信息可能出现内存泄漏的地方,可采用一些工具进行检测,比如jstack,jstat等

2.4 说说Jvm内存泄漏和内存溢出。

内存泄露:程序运行结束后,没有释放 所占用的内存空间,一次 内存泄露 可能对程序运行没有明显的影响,多次 内存泄露 最终会导致 内存溢出 
内存溢出:程序运行时,在申请内存空间时,没有足够的内存空间供其正常使用,程序运行停止,并抛出 out of memory 
引起内存溢出的原因常见的有以下几种:1.内存中加载的数据量过于庞大,如一次从数据库取出过多数据;2.集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;3.代码中存在死循环或循环产生过多重复的对象实体;
解决方案:1.修改JVM启动参数,直接增加内存。(-Xms、-Xmx 参数一定不要忘记加)2.检查错误日志,查看 “OutOfMemory” 错误前是否有其它异常或错误。3.对代码进行走查和分析,找出可能发生内存溢出的位置。

2.5 如何判断对象是否可以被回收?

引用计数器:为每个对象创建一个引用计数,有对象引用时计数器 +1,引用被释放时计数 -1,当计数器为 0 时就可以被回收。它有一个缺点不能解决循环引用的问题;
可达性分析:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GCRoots 没有任何引用链相连时,则证明此对象是可以被回收的。

2.6 Jvm中垃圾回收算法有哪些?

1、标记-清除算法
这个方法是将垃圾回收分成了两个阶段:标记阶段和清除阶段。
在标记阶段,通过根对象,标记所有从跟节点可达的对象,那么未标记的对象就是未被引用的垃圾对象。在清除阶段,清除掉所以的未被标记的对象。这个方法的缺点是,垃圾回收后可能存在大量的磁盘碎片,准确的说是内存碎片。
2、标记-整理算法在标记清除算法的基础上做了一个改进,可以说这个算法分为三个阶段:标记阶段,压缩阶段,清除阶段。标记阶段和清除阶段不变,只不过增加了一个压缩阶段,就是在做完标记阶段后,将这些未被标记过的对象集中放到一起,确定开始和结束地址,比如全部放到开始处,这样再去清除,将不会产生磁盘碎片。但是我们也要注意到几个问题,压缩阶段占用了系统的消耗,并且如果未标记对象过多的话,损耗可能会很大,在未标记对象相对较少的时候,效率较高。
3、复制算法(Java中新生代采用)核心思想是将内存空间分成两块,同一时刻只使用其中的一块,在垃圾回收时将正在使用的内存中的存活的对象复制到未使用的内存中,然后清除正在使用的内存块中所有的对象,然后把未使用的内存块变成正在使用的内存块,把原来使用的内存块变成未使用的内存块。很明显如果存活对象较多的话,算法效率会比较差,并且这样会使内存的空间折半,但是这种方法也不会产生内存碎片。
4、分代算法(Java堆采用)
主要思想是根据对象的生命周期长短特点将其进行分块,根据每块内存区间的特点,使用不同的回收算法,从而提高垃圾回收的效率。
新生代垃圾回收采用复制算法,清理的频率比较高。如果新生代在若干次清理中依然存活,则移入老年代,有的内存占用比较大的直接进入老年代。老年代使用标记清理算法,清理的频率比较低。

3 异常相关

3.1 异常的体系结构

Throwable 类是所有异常或错误的超类,它有两个子类:Error 和 Exception,分别表示错误和异常。
异常Exception分为运行时异常(RuntimeException)程序运行时发生的一床,对代码进行修改即可和编译时时异常,编译过程出现问题。
错误Error:一般是指 java 虚拟机相关的问题,如系统崩溃、虚拟机出错误、动态链接失败等,这种错误无法恢复或不可能捕获,将导致应用程序中断,通常应用程序无法处理这些错误
常见的运行时异常:
1、NullPointerException:空指针异常,调用了未经初始化的对象或者是不存在的对象。
2、ClassNotFoundException :指定的类不存在,这里主要考虑一下类的名称和路径是否正确即可 。
3、ArrayIndexOutOfBoundsException :数组下标越界异常,对数组时操作,调用的下标超过了数组的范围。
4、NoSuchMethodException:方法不存在错误。当应用试图调用某类的某个方法,而该类的定义中没有该方法
的定义时抛出该错误。
5、FileNotFoundException:文件未找到异常,进行 IO 操作时,访问的文件不存在。

3.2 throw和throws的区别是什么

throw 语句用在方法体内,表示抛出异常对象,由方法体内的语句处理。当程序出现某种逻辑错误时主动抛出一个异常实例
throws 语句用在方法声明后面,表示抛出异常,由该方法的调用者来处理。要是声明这个方法会抛出这种类型的异常
throw 与 throws 的比较
1、throws 出现在方法函数头;而 throw 出现在函数体。
2、throws 表示出现异常的一种可能性,并不一定会发生这些异常;throw 则是抛出了异常,执行 throw 则一定抛出了某种异常对象。
3、两者都是消极处理异常的方式,只是抛出或者可能抛出异常,但是不会由函数去处理异常,真正的处理异常由函数的上层调用处理。

3.3 异常的解决方式

处理异常的第一种方式:在方法声明的位置上使用throws关键字抛出,谁调用我这个方法,我就抛给谁。抛给调用者来处理。这种处理异常的态度:上报。处理异常的第二种方式:使用try..catch语句对异常进行捕捉。这个异常不会上报,自己把这个事儿处理了。异常抛到此处为止,不再上抛了。

4 多线程和锁相关

4.1 线程和进程的区别是什么?

1、进程是资源分配的最小单位,线程是程序执行的最小单位。
2、进程有自己的独立地址空间,每启动一个进程,系统就会为它分配地址空间。而线程是共享进程中的数据的,使用相同的地址空间,因此 CPU 切换一个线程的花费远比进程要小很多,同时创建一个线程的开销也比进程要小很多。
3、线程之间的通信更方便,同一进程下的线程共享全局变量、静态变量等数据,而进程之间的通信需要以通信的方式(IPC)进行。

4.2 创建线程的方式有哪些?

1.创建一个类继承Thread类,重写run方 法
2.实现Runnable接口,重写run方法 不可以获取执行的结果
3.实现Callable接口,重写call方法,结合FutureTask创建线程 可以获取执行的结果
4.还有就是通过线程池来实现

4.3 多线程之间的通信方式有哪些?

1、synchronized 同步:本质上就是“共享内存”式的通信。多个线程需要访问同一个共享变量,谁拿到了锁(获得了访问权限),谁就可以执行。
2、while 轮询:其实就是多线程同时执行,会牺牲部分 CPU 性能。不停地通过 while 语句检测条件是否成立 ,从而实现了线程间的通信。
3、wait/notify 机制通过 Object 类的 wait()和 notify()方法,切换线程状态,使线程阻塞或者运行。
4、管道通信:管道流主要用来实现两个线程之间的二进制数据的传播

4.4 线程的生命周期是什么?

请添加图片描述

4.5 sleep方法和wait方法有什么区别?

start()才是启动线程,达到多线程的目的
run()会被当做一个普通方法来调用,不会启动新的线程

4.6 如何解决线程安全的问题?

加锁
建立副本 ThreadLocal
volatile
使用线程安全的类:原子类、ConcurrentHashMap、 StringBuffer

4.7 介绍一下你了解的线程池

常见的4种线程池:
SingleThreadExecutor:创建一个单线程的线程池,这个线程池同时只能执行一个线程,可以保证线程按顺序执行,保证数据安全
FixedThreadPool:创建固定大小的线程池,比如线程池容量是10,最多可以同时执行10个线程。如果工作线程数量达到线程池初始化最大数,则将任务放入队列中
CachedThreadPool:创建一个可回收空闲线程的、按需控制线程数量的线程池 缓存线程池
ScheduledThreadPool:创建一个定时执行的线程池。
一般为了解决程序消耗的话,采用第二种就可以了,创建指定的线程数量

4.8 线程池的工作原理是什么样的?

(1)如果线程池中的线程数未达到核心线程数,则创建核心线程处理任务。
(2)如果线程数大于或者等于核心线程数,则将任务加入任务队列,线程池中的空闲线程会不断地从任务队列中取出任务进行处理。
(3)如果任务队列满了,并且线程数没有达到最大线程数,则创建非核心线程去处理任务。
(4)如果线程数超过了最大线程数,则执行饱和策略

4.9 你对锁了解多少?

常用的锁有,互斥锁和自旋锁,读写锁,乐观锁和悲观锁,其实具体使用哪种锁,需要分析业务场景中访问共享资源的方式,和访问共享资源的冲突概率。

加锁的目的:保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题

互斥锁:是一种独占锁,比如当线程A加锁成功后,此时互斥锁已经被线程A独占了,只要线程A没有释放手中的锁,线程B加锁就会失败,内核会把线程的状态从「运行」状态设置为「睡眠」状态,于是就会释放CPU让给其他线程,既然线程B释放掉了CPU,自然线程B加锁的代码就会被阻塞。

所以,如果能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁

自旋锁:自旋锁是通过 CPU 提供的 CAS 函数,在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。

互斥锁和自旋锁区别

  1. 互斥锁加锁失败后,线程会释放 CPU ,给其他线程
  2. 自旋锁加锁失败后,线程会忙等待,直到它拿到锁

读写锁

  • 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
  • 但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞
  • 根据读优先锁和写优先锁会出现一个问题,就是一方获取资源后不释放,会导致另一方永远获取不到,这种情况可以使用公平锁,利用队列把获取锁的线程排队。

乐观锁:乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。在线文档及svn,git就是一个乐观锁的场景,用户可以一起编辑,提交的时候判断版本号,如果冲突就修改提交。

悲观锁:悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。前面提到的互斥锁为悲观锁,自旋锁为乐观锁,读写锁可以为乐观锁或者悲观锁。

总结:不管使用的哪种锁,我们的加锁的代码范围应该尽可能的小,也就是加锁的粒度要小,这样执行速度会比较快。再来,使用上了合适的锁,就会快上加快了

4.10 什么是死锁?

死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去,永远处于互相等待。

4.11 如何解决死锁?

预防死锁:资源一次性分配:一次性分配所有资源,这样就不会再有请求了只要有一个资源得不到分配,也不给这个进程分配其他的资源可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反
避免死锁:系统对进程发出的每一个系统能够满足的资源申请进行动态检查,并根据检查结果决定是否分配资源;如果分配后系统可能发生死锁,则不予分配,否则予以分配。这是一种保证系统不进入死锁状态的动态策略
解除死锁:剥夺资源:从其它进程剥夺足够数量的资源给死锁进程,以解除死锁状态;撤消进程:可以直接撤消死锁进程或撤消代价最小的进程,直至有足够的资源可用,死锁状态.消除为止;所谓代价是指优先级、运行代价、进程的重要性和价值等。

4.12 死锁产出的条件

互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。
请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
环路等待条件:在发生死锁时,必然存在一个进程–资源的环形链。

4.13 分布式锁实现的方式有哪些?

1. 数据库乐观锁;
2. 基于Redis的分布式锁 setnx;
3. 基于ZooKeeper的分布式锁。
项目中采用redis的分布式锁

4.14 项目中如何采用分布式锁

在项目中我们采用redis的分布式锁的方式,对秒杀和活动的库存进行控制,在项目中有个活动是用户抢优惠券,在每周三,周五早8,10都会有抢优惠券活动,需求是这样的,当用户领完一张优惠券之后,优惠券的数量必须减一,如果优惠券抢光了,就不允许再抢了。在实现的时候,先从数据库中读出优惠券的数量进判断,当优惠券大于0,就允许用户进行领取优惠券,然后将优惠券的和数量减一,再写回数据库,当时考虑请求比较多,就使用了三台服务器去做分流,当时做的时候就出现一个问题:其中一台服务器上的 A 应用获取到了优惠券的数量之后,由于处理相关业务逻辑,未及时更新数据库的优惠券数量;在 A 应用处理业务逻辑的时候,另一台服务器上的 B 应用更新了优惠券数量。那么,等 A 应用去更新数据库中优惠券数量时,就会把 B 应用更新的优惠券数量覆盖掉。当时考虑了三种解决方式:第一就是使用sql语句更新数据库,但是在没有分布式锁的时候优惠券可能出现负数,就是两个服务器同时发起抢券请求,都满足大于0的条件,就出现负数了,第二是使用乐观锁,但是会存在卡顿的情况,就是数据如果更新补上,导致长时间重试,第三就是使用redis的分布式锁了,通过锁互斥,防止多个客户端去同时更新优惠券数量。当时想到的就是使用redis的setnx命令,就是set if not exist,当key设置成功后,返回1,否则就返回0,所以这里 setnx 设置成功可以表示成获取到锁,如果失败,则说明已经有锁,可以被视作获取锁失败。释放的话直接使用del,把key删除 就可以了,利用这个特性,让系统执行优惠券逻辑之前,先去redis中执行setnx命令,再根据指令的结果,判断是否获取到锁,如果获取到了就继续执行任务,执行完在使用del释放,如果没有获取到就等一段时间,再重新获取锁。其实在运行的过程中还碰到一个问题,就是持有锁的应用突然崩溃了,会造成死锁,因为没有释放,其他应用没机会获取锁,就造成了线上事故,当时是在key上加了一个过期时间,这样如果出现了问题,在一段时间后也会释放锁,不过setnx本身没办法设置超时时间,这里我用的lua脚本实现的redis的原子性,它的核心命令就是eval使用setnx命令后,再使用expire命令给key设置过期时间。释放都使用del。其实除了在项目之外我还自己使用过redission(看门狗),因为其实使用redis+lua虽然实现了原子性,但是也存在问题,就是redis自身的隐患,因为lua脚本是用在redis的单实例上面,一旦redis本身出现问题,那么分布式锁就没法用,所以要搞成高可用的,但是redis的主从同步有延迟,这个延迟会产生一个边界条件,当主机的redis被建好锁了,还没有同步到从机,主机宕机了,然后从机升为主机,这个时候从机是没有主机设置好的锁数据,这个时候锁丢了,所以就可以采用redission开源的redis客户端,它会对集群中的每个 Redis,挨个去执行设置 Redis 锁的脚本,也就是集群中的每个 Redis 都会包含设置好的锁数据。其实这个也存在争议,具体使用还得看项目适合哪种。

5 集合相关

5.1 java中的集合有哪些?

java 中的集合分为单列集合和双列集合,单列集合顶级接口为 Collection,双列集合顶级接口为 Map。
Collection 的子接口有两个:List 和 Set。
List 接口的特点:元素可重复,有序(存取顺序)。
list 接口的实现类:ArrayList:底层实现是数组,查询快,增删慢,线程不安全,效率高;Vector:底层实现是数组,查询快,增删慢,线程安全,效率低;【废弃】LinkedList:底层实现是链表,增删快,查询慢,线程不安全,效率高;
Set 接口的特点:元素唯一,不可重复,无序。
Set 接口实现类:HashSet:底层实现 hashMap,数组+链表实现,不允许元素重复,无序。TreeSet:底层实现红黑二叉树,实现元素排序
Map 接口的特点:key-value 键值对形式存储数据
Map 接口实现类:HashMap:底层数组+链表实现,线程不安全效率高;TreeMap:底层红黑二叉树实现,可实现元素的排序;LinkedHashMap:底层 hashmap+linkedList 实现,通过 hashmap 实现 key-value 键值对存储,通过链表实现元素有序。

5.2 HashMap底层原理

1、HashMap 底层是数组+链表(LinkedList)实现,hashMap 默认初始化容量为 16,每个数组中存储一个表。
2、当 hashmap 空间使用达到 0.75 后,会对数组进行扩容,新建数组,然后将元素拷贝到新的数组中,每次扩容翻倍。
3、存储元素时,存储对象为 Map.Entry,entry 对象包含四个信息,key、value、hash 值、链表地址值,因为存储元素时,会根据 hash 值%16 计算,元素存储数组中的索引位置。
4、但是有可能发生 hash 碰撞现象,即两个元素不相同,却有一样的 hash 值,这样的话,就将元素在数组中存入链表中,以链表的形式进行元素的存储,第一个 entry 存在链表顶端,再有 hash 值一致的 entry 存入,则链接在第一个元素之后。
5、put()方法存储元素时,根据 hash 值定位数组,在链表中通过 HashCode()和 equals()方法,定位元素的 key,若没有一致的 entry,则在链表中添加 entry 对象,若找到一样的 entry,则将 oldValue 返回,将新的 value 存入到 entry 中。

5.3 ArrayList 和 Linkedlist 区别?

相同点:1、都是 List 接口的实现类,具有元素可重复,有序(存取顺序)特点;2、都是线程不安全,效率高;
不同点:1、数据结构:ArrayList:是动态数组,内存连续,LinkedList:是双向链表内存不连续;2、随机访问效率:ArrayList比 LinkedList 效率要高,因为LinkedList 是线性的数据存储方式,所以需要移动指针从前往后依次查找。3、增加和删除效率:在非首尾的增加和删除操作,LinkedList 要比ArrayList 效率要高,因为ArrayList 增删操作会影响节点后的其他元素的位置,重新进行存入

5.4 concurrentHashMap的底层结构?

锁结构+数据结构(HashMap),concurrentHashMap 本质就是加了锁的HashMap
1.锁结构不同JDK1.7时:ConcurrentHashMap基于Segment+Entry数组实现的。Segment是Reentrant的子类,而其内部也维护了一个Entry数组(这个Entry数组和HashMap中的Entry数组是一样的)。Segment其实是一个锁,可以锁住一段哈希表(数组+链表|红黑树)结构,而ConcurrentHashMap中维护了一个Segment数组,所以是基于分段锁实现的。JDK1.8时:ConcurrentHashMap摒弃了Segment,而是采用synchronized+CAS(乐观锁:比较替换+循环)来实现的。锁的粒度也从段锁缩小为结点锁
2.put()的执行流程有所不同JDK1.7ConcurrentHashMap要进行两次定位,先对Segment进行定位,再对其内部的数组下标进行定位。定位之后会采用加锁机制。并且在整个put操作期间都持有锁。JDK1.8
只需要一次定位,并且采用CAS+synchronized的机制。如果对应下标处没有结点,说明没有发生哈希冲突,此时直接通过CAS进行插入,若成功,直接返回。若失败,则使用synchronized进行加锁插入。

5.5 hashMap 和 和 hashTable 的区别?

相同点:1、二者都是 key-value 的双列集合;2、底层都是通过数组+链表方式实现数据的存储;
不同点:1、继承的父类不同Hashtable 继承自 Dictionary 类,而 HashMap 继承自 AbstractMap 类。但二者都实现了 Map 接口。2、线程安全性不同Hashtable 中的方法是 Synchronize 的,而 HashMap 中的方法在缺省情况下是非 Synchronize 的。在多线程并发的环境下,可以直接使用 Hashtable,不需要自己为它的方法实现同步,但使用 HashMap 时就必须要自己增加同步处理。3、hashMap 允许 null 键和 null 值,只能有一个,但是 hashtable 不允许。4、HashMap 是 java 开发中常用的类,但是 Hashtable 和 vector 一样成为了废弃类,不推荐使用,因为有其他高效的方式可以实现线程安全。

6 javaweb相关

6.1 说一下servlet的生命周期

1、init():Servlet 初始化方法,仅会执行一次。
2、service():Servlet 服务方法,主要接受请求,处理请求,响应请求。一旦有 request 请求,则会自动调用,会执行多次。
3、destory():Servlet 销毁方法,释放资源。一般来说不会调用,只有当服务器关闭时,才会调用,且仅调用一次。

6.2 cookie 和 和 session 的区别?

1、cookie 数据存放在客户端的浏览器上,session 数据放在服务器上。
2、cookie 不是很安全,别人可以分析存放在本地的 cookie 并进行 cookie 欺骗,考虑到安全应当使用 session。
3、session 会在一定时间内保存在服务器上。当访问增多,会比较占用你服务器的性能,考虑到减轻服务器性能方面,应当使用 cookie。
4、单个 cookie 保存的数据不能超过 4K,很多浏览器都限制一个站点最多保存 20 个 cookie。
5、可以考虑将登陆信息等重要信息存放为 session,其他信息如果需要保留,可以放在 cookie 中。

6.3 cookie 浏览器禁止后,session 还有效么?

1、session 会话,会以 key-value 的形式,将 session 会话对象保存在服务器的 session 池中
2、session池中,JSESSIONID作为key,session对象作为value,每次会话session会将JSESSIONID存入cookie,下次请求则根据 cookie 中的 JSESSIONID,获取 session 会话对象。
3、如果浏览器 cookie 被禁用,则通过 URL 重定向的形式传递 JSESSIONID,实际上就是已 url 参数的形式直接拼接 JSESSIONID=XXXXXX,保证服务器可以根据请求中的 JESSONID 找到对应的 session 会话。

7 锁

7.1 常用的锁

常用的锁有,互斥锁和自旋锁,读写锁,乐观锁和悲观锁,其实具体使用哪种锁,需要分析业务场景中访问共享资源的方式,和访问共享资源的冲突概率。

加锁的目的:保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题

互斥锁:是一种独占锁,比如当线程A加锁成功后,此时互斥锁已经被线程A独占了,只要线程A没有释放手中的锁,线程B加锁就会失败,内核会把线程的状态从「运行」状态设置为「睡眠」状态,于是就会释放CPU让给其他线程,既然线程B释放掉了CPU,自然线程B加锁的代码就会被阻塞。

所以,如果能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁

自旋锁:自旋锁是通过 CPU 提供的 CAS 函数,在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。

互斥锁和自旋锁区别

  1. 互斥锁加锁失败后,线程会释放 CPU ,给其他线程
  2. 自旋锁加锁失败后,线程会忙等待,直到它拿到锁

读写锁

  • 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
  • 但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞
  • 根据读优先锁和写优先锁会出现一个问题,就是一方获取资源后不释放,会导致另一方永远获取不到,这种情况可以使用公平锁,利用队列把获取锁的线程排队。

乐观锁:乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。在线文档及svn,git就是一个乐观锁的场景,用户可以一起编辑,提交的时候判断版本号,如果冲突就修改提交。

悲观锁:悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。前面提到的互斥锁为悲观锁,自旋锁为乐观锁,读写锁可以为乐观锁或者悲观锁。

总结:不管使用的哪种锁,我们的加锁的代码范围应该尽可能的小,也就是加锁的粒度要小,这样执行速度会比较快。再来,使用上了合适的锁,就会快上加快了

7.2 分布式锁一般有三种实现方式:

  1. 数据库乐观锁;
  2. 基于Redis的分布式锁 setnx;
  3. 基于ZooKeeper的分布式锁。

7.3 分布式锁需满足以下四个条件:

  1. 互斥性。在任意时刻,只有一个客户端能持有锁。
  2. 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  3. 具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
  4. 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。

在项目中我们采用redis的分布式锁的方式,对秒杀和活动的库存进行控制,在项目中有个活动是用户抢优惠券,在每周三,周五早8,10都会有抢优惠券活动,需求是这样的,当用户领完一张优惠券之后,优惠券的数量必须减一,如果优惠券抢光了,就不允许再抢了。

在实现的时候,先从数据库中读出优惠券的数量进判断,当优惠券大于0,就允许用户进行领取优惠券,然后将优惠券的和数量减一,再写回数据库,当时考虑请求比较多,就使用了三台服务器去做分流,当时做的时候就出现一个问题:其中一台服务器上的 A 应用获取到了优惠券的数量之后,由于处理相关业务逻辑,未及时更新数据库的优惠券数量;在 A 应用处理业务逻辑的时候,另一台服务器上的 B 应用更新了优惠券数量。那么,等 A 应用去更新数据库中优惠券数量时,就会把 B 应用更新的优惠券数量覆盖掉。

当时考虑了三种解决方式:第一就是使用sql语句更新数据库,但是在没有分布式锁的时候优惠券可能出现负数,就是两个服务器同时发起抢券请求,都满足大于0的条件,就出现负数了,第二是使用乐观锁,但是会存在卡顿的情况,就是数据如果更新补上,导致长时间重试,第三就是使用redis的分布式锁了,通过锁互斥,防止多个客户端去同时更新优惠券数量。

当时想到的就是使用redis的setnx命令,就是set if not exist,当key设置成功后,返回1,否则就返回0,所以这里 setnx 设置成功可以表示成获取到锁,如果失败,则说明已经有锁,可以被视作获取锁失败。释放的话直接使用del,把key删除 就可以了,利用这个特性,让系统执行优惠券逻辑之前,先去redis中执行setnx命令,再根据指令的结果,判断是否获取到锁,如果获取到了就继续执行任务,执行完在使用del释放,如果没有获取到就等一段时间,再重新获取锁。其实在运行的过程中还碰到一个问题,就是持有锁的应用突然崩溃了,会造成死锁,因为没有释放,其他应用没机会获取锁,就造成了线上事故,当时是在key上加了一个过期时间,这样如果出现了问题,在一段时间后也会释放锁,不过setnx本身没办法设置超时时间,这里我用的lua脚本实现的redis的原子性,它的核心命令就是eval使用setnx命令后,再使用expire命令给key设置过期时间。释放都使用del。

其实除了在项目之外我还自己使用过redission(看门狗),因为其实使用redis+lua虽然实现了原子性,但是也存在问题,就是redis自身的隐患,因为lua脚本是用在redis的单实例上面,一旦redis本身出现问题,那么分布式锁就没法用,所以要搞成高可用的,但是redis的主从同步有延迟,这个延迟会产生一个边界条件,当主机的redis被建好锁了,还没有同步到从机,主机宕机了,然后从机升为主机,这个时候从机是没有主机设置好的锁数据,这个时候锁丢了,所以就可以采用redission开源的redis客户端,它会对集群中的每个 Redis,挨个去执行设置 Redis 锁的脚本,也就是集群中的每个 Redis 都会包含设置好的锁数据。

其实这个也存在争议,具体使用还得看项目适合哪种。


本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!

相关文章

立即
投稿

微信公众账号

微信扫一扫加关注

返回
顶部