java基础篇.md 19 KB

面试专题-java基础篇

1. 什么是面向对象?谈谈你对面向对象的理解

面向对象三大特性

封装:封装的意义,在于明确标识出允许外部使用的所有成员函数和数据项 内部细节对外部调用透明,外部调用无需修改或者关心内部实现

1、javabean的属性私有,对外提供get/set访问,因为属性的赋值或者获取逻辑只能由javabean本身决定。而不能由外部胡乱修改该name有自己的命名规则,明显不能由外部直接赋值

2、ORM框架

例如:操作数据库,我们不需要关心连接是如何建立的、Sq|是如何执行的,只需要引入mybatis,调方法即可

继承:继承基类的方法,并做出自己的改变和/或扩展

子类共性的方法或者属性直接使用父类的,而不需要自己再定义,只需扩展自己个性化的属性方法即可

例如:自定义异常继承RuntimeException后扩容自己属性或者方法、MP提供公共持久层接口BaseMapper业务层提供公共IService及ServiceImpl继承父类中属性或方法,扩展自定义方法属性(微信登录方法)

多态:基于对象所属类的不同,外部对同一个方法的调用,实际执行的逻辑不同。 父类引用指向子类对象,同时父类无法调用子类特有功能

例如:Map接口包含很多实现类;采用策略模式:策略接口下包含多个不同策略实现类

2. 深拷贝与浅拷贝的理解

开发中经常遇到,把父类的属性拷贝到子类中。通常有2种方法:

  • 一个一个set(深拷贝)
  • 用BeanUtil.copyProperties(浅拷贝)

深拷贝和浅拷贝就是指对象的拷贝,一个对象中存在两种类型的属性,一种是基本数据类型,一种是实例对象的引用。

浅拷贝:在拷贝一个对象时,对对象的基本数据类型的成员变量进行拷贝,但对引用类型的成员变量只进行引用的传递,并没有创建一个 新的对象,当对引用类型的内容修改会影响被拷贝的对象。

public static void main(String[] args) {
    //采用浅拷贝:只进行引用的传递,并没有创建一个 新的对象
    User user = new User();
    user.setId(1);
    user.setName("jack");
    Address address = new Address(100, "北京昌平");
    user.setAddress(address);

    //调用spring,hutool工具包提供拷贝方法
    User newUser = BeanUtil.copyProperties(user, User.class);
    System.out.println(newUser);
    //修改地址对象中属性:地名
    address.setAddress("北京");
    System.out.println(newUser);
}

深拷贝:,在拷贝一个对象时,除了对基本数据类型的成员变量进行拷贝,对引用类型的成员变量进行拷贝时,创建一个新的对象来保存引用类型的成员变量; 采用序列化方式,或者对象clone方法

public static void main(String[] args) {
    //采用浅拷贝:只进行引用的传递,并没有创建一个 新的对象
    User user = new User();
    user.setId(1);
    user.setName("jack");
    Address address = new Address(100, "北京昌平");
    user.setAddress(address);

    //调用spring,hutool工具包提供拷贝方法
    //User newUser = BeanUtil.copyProperties(user, User.class);
    //System.out.println(newUser);
    ////修改地址对象中属性:地名
    //address.setAddress("北京");
    //System.out.println(newUser);


    //采用深拷贝:采用序列化方式(将对象转为字节数组,JSON方式)
    //1.将对象转为JSON字符串-序列化
    String addresStr = JSON.toJSONString(address);
    //2.将JSON字符串转为Java对象-反序列化
    Address newAddress = JSON.parseObject(addresStr, Address.class);
    System.out.println(newAddress);
    address.setAddress("北京");
    System.out.println(newAddress);
}

3. String、 StringBuffer StringBuilder的区别

  1. String是不可变的,如果尝试去修改,会新生成一个字符串对象,StringBuffer和StringBuilder是可变的

  2. StringBuffer是线程安全的,StringBuilder是线程不安全的,所以在单线程环境下StringBuilder效率会更高(方法中局部比变量使用且没有方法中开启多线程操作,可以使用)

4. 接口与抽象类有什么区别

  1. 定义
    • 接口:接口是一种抽象数据类型,它只定义了一组方法的签名(即方法名称、返回类型和参数列表),但没有方法的实现(可以提供方法默认实现default关键字修饰)。接口中的方法默认为抽象方法,不包含具体的代码。
    • 抽象类:抽象类是一个类,可以包含抽象方法(即没有方法体的方法),同时也可以包含具体方法。抽象类可以有实例变量,并且可以被继承。 不支持new抽象类
  2. 多继承
    • 接口:Java支持多实现接口,一个类可以实现多个接口。这使得类能够获得多个不同接口的方法。
    • 抽象类:Java不支持多重继承的类,一个类只能继承一个抽象类。
  3. 构造方法
    • 接口:接口不能包含构造方法,因为它们不能实例化。
    • 抽象类:抽象类可以包含构造方法(不能new实例对象),可以用于初始化对象状态。
  4. 字段(属性)
    • 接口:接口中的字段默认常量(public static final),必须初始化,并且只能包含常量。
    • 抽象类:抽象类可以包含实例字段,可以有不同的访问修饰符,可以被子类继承。
  5. 实现
    • 接口:类通过实现接口来获得接口中定义的方法。类必须提供方法的实现。
    • 抽象类:子类通过继承抽象类来获得方法。子类可以选择实现或覆盖抽象方法。
  6. 用途
    • 接口:适用于定义契约,强制实现多态性,允许类遵循多个不同的契约。
    • 抽象类:适用于定义类的基本结构,提供一些通用的方法和数据,允许代码重用。

5. wait与sleep有什么区别?

一个共同点,三个不同点

共同点

wait() 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态

不同点

  • 方法归属不同

sleep(long) 是 Thread 的静态方法 wait()都是 Object 的成员方法,每个对象都有

  • 醒来时机不同

执行 sleep(long) 的线程会在等待相应毫秒后醒来, wait() 可以被 notify 唤醒,wait() 如果不唤醒就一直等下去

它们都可以被打断唤醒

  • 锁特性不同(重点)

wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制

wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,释放锁,其他线程再次获取锁)

而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,其他线程无法获取锁)

6. 线程池相关问题

6.1. 对线程池的理解

线程池是一种池化技术,其实是一种资源复用思想的利用 常见的比如像线程池,连接池 内存池 对象池等这些

线程池做的工作主要是控制运行的线程的数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最 大数量超出数量的线程排队等候,等其它线程执行完毕,再从队列中取出任务来执行。

主要特点:线程复用;控制最大并发数:管理线程。

第一:降低资源消耗。通过重复利用己创建的线程降低线程创建和销毁造成的消耗。

第二:提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。

第三:提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳

定性,使用线程池可以进 行统一的分配,调优和监控

6.2. 线程池底层工作原理

img

image-20240612114450167

第一步:线程池刚创建的时候,默认里面没有任何线程,等到有任务过来的时候才会创建线程。也可以调用

prestartAllCoreThreads() 或者 prestartCoreThread() 方法预创建corePoolSize个线程

第二步:调用execute()提交一个任务时,如果当前的工作线程数<corePoolSize,直接创建新的核心线程执行这个任务

第三步:如果当时工作线程数量>=corePoolSize,会将任务放入任务队列中缓存(选择有界阻塞队列)

第四步:如果队列已满,并且线程池中工作线程的数量<maximumPoolSize,还是会创建线程执行这个任务,非核心线程任务完成后,空闲时间大于规定阈值销毁非核心线程

第五步:如果队列已满,并且线程池中的线程已达到maximumPoolSize,这个时候会执行拒绝策略,JAVA线程池

默认的策略是AbortPolicy,即抛出RejectedExecutionException异常

6.3. 如何自定义线程池参数

两种方式:

  • JDK线程池:ThreadPoolExecutor 严禁使用Executors声明线程池(线程数或阻塞队列为Integer最大值)
  • Spring线程池:ThreadPoolTaskExecutor
corePoolSize 核心线程数目 - 池中会保留的最多线程数
maximumPoolSize 最大线程数目 - 核心线程+救急线程的最大数
keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
workQueue 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略

6.4. 阻塞队列

常用的实现类

LinkedBlockingQueue:可以利用碎片化空间提升内存的利用率,默认队列长度2的31次方-1相当于无界阻塞队列,容易操作任务大量堆积造成堆内存溢出

ArrayBlackingQueue: 空间连续,指定队列长度

该参数是实际开发中重点需要调优和动态调整的参数, 队列的大小可以由项目所占内存大小和对应接口的吞吐量决定

6.5.如何配置合理的线程数

CPU密集型

CPU密集的意思是该任务需要大量的运算,而没有阻塞,CPU一直全速运行。CPU密集任务只有在真正的多核CPU上才可能得到加速(通过多线程),而在单核CPU上,无论你开几个模拟的多线程该任务都不可能得到加速,因为CPU总的运算能力就那些。CPU密集型任务配置尽可能少的线程数量:

一般公式:CPU核数+1个线程的线程池

IO 密集型

由于IO密集型任务线程并不是一直在执行任务,则应配置尽可能多的线程,如CPU核数*2 I0密集型,即该任务需要大量的I0,即大量的阻塞。在单线程上运行I0密集型的任务会导致浪费大量的CPU运算能力浪费在等待。所以在IO密集型任务中使用多线程可以大大的加速程序运行,即使在单核CPU上,这种加速主要就是利用了被浪费掉的阻塞时间。

一般公式

  • CPU核数 / (1-0.9),核数为4的话,一般设置 40
  • 2*CPU核数+1 9

6.6. 线程池拒绝策略有哪些?

拒绝/饱和策略分类 含义 使用场景
AbortPolicy 丢弃任务并抛出异常 RejectedExecutionException 我们项目中关于线程池的定义,使用的就是默认的如果这种需求是关键的业务,eg:商品详情/购物车/首页
DiscardPolicy 安静的丢弃任务但是不抛出异常 设计的时候,一些无关紧要的业务可以采用此策略Eg:单纯的展示某一项数据的情况 文章的浏览量/点赞个数
DiscardOldestPolicy 丢弃队列最前面的任务,然后重新提交被拒绝的任务 喜新厌旧使用场景不多,可根据特定场景使用
CallerRunsPolicy 由调用线程处理该任务 使用场景非常少

7.synchronized和Lock锁

image-20230303111211678

Synchronized 锁的升级过程

在Java中,synchronized关键字的锁升级过程是一个动态的过程,旨在提高并发性能并减少线程之间的争用。这个过程从最初的无锁状态开始,根据线程对锁的争用情况,逐步升级到更高级别的锁状态。

对象头信息:

我们来看一下他的升级过程:

无锁状态

对象刚被创建时,没有线程对其加锁,此时处于无锁状态。

偏向锁

  • 当第一个线程访问某个对象并尝试获取锁时,JVM会利用CAS(Compare-And-Swap)操作在对象的对象头(Mark Word)中记录下当前线程的ID和偏向锁标记位(通常设置为1)。
  • 如果下一次还是这个线程访问该对象,则只需要检查对象头中的线程ID是否与自己的ID相同,如果相同则直接获得锁,无需再进行CAS操作。这种情况下,锁就保持在偏向锁状态,整个过程几乎没有任何性能开销。
  • 如果在持有偏向锁期间,其他线程尝试访问该对象并获取锁,偏向锁会被撤销,并尝试升级为轻量级锁。

轻量级锁

  • 当偏向锁被撤销后,锁会升级到轻量级锁状态。
  • 在轻量级锁状态下,JVM会在当前线程的栈帧中创建一个锁记录(Lock Record),并将对象头中的Mark Word复制到该锁记录中,同时对象头中会有一个指针指向这个锁记录。
  • 当前线程会进入自旋(Spinning)状态,即不断尝试重新获取锁,而不是立即阻塞。自旋的目的是为了避免线程切换带来的性能开销,因为线程切换涉及到操作系统层面的操作,开销相对较大。
  • 如果自旋过程中成功获取到锁,则继续执行后续代码;如果自旋超过一定次数(通常是10次)仍未获取到锁,或者有其他线程参与锁竞争,则轻量级锁会膨胀为重量级锁。

重量级锁

  • 当轻量级锁无法满足并发需求时,锁会升级为重量级锁。
  • 在重量级锁状态下,如果当前线程未获取到锁,则会进入阻塞状态,等待其他线程释放锁。当锁被释放后,阻塞的线程会被唤醒并重新尝试获取锁。
  • 重量级锁的实现依赖于操作系统的互斥量(Mutex)或其他同步机制,因此涉及到用户态和内核态的切换,开销相对较大。

总结

  • synchronized的锁升级过程是从无锁状态开始,根据线程对锁的争用情况逐步升级到偏向锁、轻量级锁和重量级锁的过程。
  • 偏向锁和轻量级锁是JVM为了提高并发性能而引入的优化措施,它们可以减少线程切换带来的性能开销。
  • 重量级锁是当轻量级锁无法满足并发需求时的最终选择,它依赖于操作系统的同步机制来实现。

8. 死锁问题

8.1 什么是死锁?

死锁是指由于两个或者多个线程互相持有对方所需要的资源,导致这些线程处于等待状态,无法前往执行

8.2. 诱发死锁产生的原因有哪些?

  • 互斥条件(Mutual Exclusion):至少有一个资源是不可共享的,一次只能被一个线程占有。

  • 持有并等待(Hold and Wait):线程因已经获得了某些资源而在等待其他资源时,并不释放自己已获得的资源。

  • 不可剥夺(No Preemption):资源一旦被占有,除非该线程自行释放,否则无法被其他线程抢占。

  • 循环等待(Circular Wait):存在一个线程等待队列,在这个等待队列中,每个线程都在等待下一个线程所占有的资源。

8.3 遇到死锁问题如何解决?

死锁一旦发生,其实基本上就很难人为干预解决他,所有我们只能尽可能的规避他 上面提到的四个条件,只要同时满足了就会触发死锁 所有我们只需打破其中任意一条,死锁自然也就不会存在了 举例说明:

​ 第一条:互斥条件 这个作为锁所必须的条件,无法干预,避免多个线程同时获取多个锁

​ 第二条:可以一次性申请所需的所有资源 此时就不存在等待的问题

​ 第三条: 当其中一个线程再去申请资源的时候,如果申请不到,失败时立即返回,而不是等待。

​ 第四条: 设计一个锁的顺序,保证所有线程都按照相同的顺序获取锁

死锁案例代码

public class DeadThread extends Thread {

    // 定义成员变量,来切换线程去执行不同步代码块的执行
    private boolean flag ;
    public DeadThread(boolean flag) {
        this.flag = flag ;
    }

    @Override
    public void run() {

        if(flag) {

            synchronized (MyLock.R1) {   
                System.out.println(Thread.currentThread().getName() + "---获取到了R1锁,申请R2锁....");
                synchronized (MyLock.R2) {
                    System.out.println(Thread.currentThread().getName() + "---获取到了R1锁,获取到了R2锁....");
                }
            }

        }else {
            synchronized (MyLock.R2) {	
                System.out.println(Thread.currentThread().getName() + "---获取到了R2锁,申请R1锁....");
                synchronized (MyLock.R1) {
                    System.out.println(Thread.currentThread().getName() + "---获取到了R2锁,获取到了R1锁....");
                }
            }
        }
    }
}

锁接口

public interface MyLock {
    // 定义锁对象
    public static  final Object R1 = new Object() ;
    public static  final Object R2 = new Object() ;
}

测试类

public class DeadThreadDemo1 {

    public static void main(String[] args) {

        // 创建线程对象
        DeadThread deadThread1 = new DeadThread(true) ;
        DeadThread deadThread2 = new DeadThread(false) ;

        // 启动两个线程
        deadThread1.start();
        deadThread2.start();

    }

}

阿里巴巴中最新的开发规约,里面有对避免死锁的说明,具体如下:


【强制】对多个资源、数据库表、对象同时加锁时,需要保持一致的加锁顺序,否则可能会造成死锁。 说明:线程一需要对表 A、B、C 依次全部加锁后才可以进行更新操作,那么线程二的加锁顺序也必须是 A、B、C,否则可能出现死锁。