2023面试知识点二

1、vue双向绑定是如何实现的

  • 原理主要通过数据劫持和发布订阅模式实现的
  • 通过Object.defineProperty()来劫持各个属性的settergetter,监听数据的变化
  • 在数据变动时发布消息给订阅者(watcher),订阅者触发响应的回调(update)更新视图。

2、ingress在k8s体系中起到什么作用

ingress和Service、Deployment,也是一个k8s的资源类型,ingress用于实现用域名的方式访问k8s内部应用。

Ingress为Kubernetes集群中的服务提供了入口,在生产环境中常用的Ingress有Treafik、Nginx、HAProxy、Istio等。

Ingress用于从集群外部到集群内部Service的HTTP和HTTPS路由,流量从Internet到Ingress再到Services最后到Pod上,通常情况下,Ingress部署在所有的Node节点上。

Ingress可以配置提供服务外部访问的URL、负载均衡、终止SSL,并提供基于域名的虚拟主机。但Ingress不会暴露任意端口或协议。

3、选举算法有哪些

选举算法是指在多个候选人中选出一个或多个胜出者的算法。常见的选举算法有以下几种:

  1. 多数投票算法(Plurality Voting):每个选民只能投一票,得票最多的候选人胜出。这是最常见的选举方式,但容易出现“分裂票”现象。

  2. 多数排名算法(Instant-runoff Voting):选民按照候选人的顺序进行排名,第一轮计票时,先统计每个候选人的第一名票数,如果有候选人得票超过半数,则该候选人胜出;否则,得票最少的候选人被淘汰,其支持者的第二选择被重新计入投票结果中,直到某个候选人获得超过半数的票数。

  3. 学术界常用的评价指标算法(Borda Count):每个选民对所有候选人进行排名,第一名得到n-1分,第二名得到n-2分,以此类推,最后将每个候选人得到的分数相加,得分最高的候选人胜出。

  4. 同时投票算法(Approval Voting):每个选民可以投给一个或多个候选人一票,得票最多的候选人胜出。

  5. 博物馆投票算法(Range Voting):每个选民对每个候选人进行打分,最后将每个候选人得到的分数相加,得分最高的候选人胜出。

4、https和http的区别,http协议的不同版本有什么区别

http中文全称叫超文本传输协议,英文全称HyperText Transfer Protocol,取的就是英文首字母,属于应用层协议,一般用于web浏览器和网站服务器之间传递信息。

https比http晚出来,英文全称是Hypertext Transfer Protocol Secure,本质还是http协议,后面加了Secure,很明显是为了解决http传输中的安全性问题。

区别如下:

1、HTTP 明文传输,数据都是未加密的,安全性较差,HTTPS(SSL+HTTP) 数据传输过程是加密的,安全性较好。

2、使用 HTTPS 协议需要到 CA(Certificate Authority,数字证书认证机构) 申请证书,一般免费证书较少,因而需要一定费用。证书颁发机构如:Symantec、Comodo、GoDaddy 和 GlobalSign 等。

3、http 和 https 使用的是完全不同的连接方式,用的端口也不一样,前者是 80,后者是 443。

4、https注重安全性,比http页面加载时间慢,对服务器资源消耗大。

5、网关如何处理海量的黑白名单

对于海量的黑白名单,可以考虑使用Bloom Filter算法来进行优化。Bloom Filter是一种空间效率很高的随机数据结构,它利用位数组很简洁地表示一个集合,并能判断一个元素是否属于这个集合。在Spring Cloud Gateway中,可以使用Guava库提供的Bloom Filter实现来进行黑白名单的过滤。

Bloom Filter是一种快速判断一个元素是否存在于一个集合中的算法。它的原理是使用多个哈希函数对元素进行哈希,将哈希值映射到一个位数组中,并将对应的位标记为1。当需要判断一个元素是否存在于集合中时,同样使用多个哈希函数对该元素进行哈希,检查对应的位是否都为1,若有任意一位为0,则可以确定该元素不在集合中;若所有位都为1,则该元素可能在集合中(因为有可能其他元素的哈希值也映射到了这些位上)。因此,Bloom Filter可能会出现误判,但不会出现漏判。

6、sentinel限流算法有哪些

Sentinel包含了多种限流算法。常用的限流算法有以下几种:

  1. 计数器算法:简单来说就是对请求进行计数,当请求数量超过设定的阈值时进行限流。这种算法简单易懂,但是无法应对突发流量。

  2. 滑动窗口算法:将时间分成若干个时间段,每个时间段内都有一个计数器,记录该时间段内的请求数量。随着时间的推移,最早的时间段被删除,最新的时间段被添加。这种算法可以应对突发流量,但是需要占用较多的内存。

  3. 令牌桶算法:将请求看作令牌,令牌以固定速率被放入桶中。当请求到来时,需要从桶中获取令牌,如果桶中没有足够的令牌,则进行限流。这种算法可以应对突发流量,且可以通过调整放入桶中令牌的速率来控制流量。

  4. 漏桶算法:将请求看作水滴,水滴以固定速率从漏桶中流出。当请求到来时,需要将水滴放入漏桶中,如果漏桶已满,则进行限流。这种算法可以应对突发流量,但是无法应对短时间内的流量波动。

7、redis的插槽在分片中如何移动

Redis使用哈希槽(Hash Slot)来管理分片(Sharding)。在Redis Cluster中,共有16384个哈希槽(0-16383),每个槽负责存储部分数据。不同分片(Redis节点)会持有不同槽的数据。

当新的Redis节点加入集群或某个节点从集群中移除时,槽的移动通常是由Redis Cluster自动处理的。以下是槽的移动和处理的一般情况:

  1. 初始分配槽:在集群启动或有新节点加入时,槽会被均匀地分配到可用的节点上。

  2. 节点加入:当一个新的节点加入Redis Cluster时,一部分槽将被迁移到新节点。这个过程称为槽的迁移。Redis Cluster会自动处理这个过程,确保数据均匀地分布。

  3. 节点移除:如果一个节点被移除(例如,宕机或主动退出),其槽将被重新分配到其他节点上。Redis Cluster也会自动处理这个过程,确保数据的连续性。

  4. 手动迁移:在某些情况下,您可能需要手动迁移槽。您可以使用CLUSTER SETSLOT命令来手动指定槽应该属于哪个节点。这通常用于维护或调整集群。

在这些情况下,Redis Cluster会负责将槽的数据从一个节点移动到另一个节点,以确保数据的完整性和可用性。这种自动化的处理对于运维来说是一个很大的帮助,因为它减少了手动干预的需求。

需要注意的是,Redis Cluster的槽迁移和分布算法在设计上保证了尽可能的高可用性和负载均衡。这确保了在集群中的节点故障或加入时,数据能够平稳地迁移和重新平衡。

插槽出现之前是如何处理

在Redis Cluster出现之前,Redis没有自带的分布式解决方案,且不支持自动的水平分片。在这种情况下,通常需要使用客户端代理或手动将数据分片到不同的Redis实例中。

一种常见的方法是使用客户端代理,例如Twemproxy(nutcracker)或自定义的代理层。这些代理会拦截应用程序的请求,并将其路由到底层的多个Redis实例之一。这样,应用程序可以将数据分散在多个Redis实例上,但应用程序本身不必直接管理这个过程。

另一种方法是手动分片。在这种情况下,应用程序的开发人员需要根据某种规则(例如,数据键的散列)将数据分散到不同的Redis实例中。这就需要开发者自己来处理数据的分布和请求的路由,确保数据平均地分布在各个实例上。这样的手动分片可以通过在应用层实现,也可以通过在数据库代理层实现,具体取决于应用的需求和架构。

这两种方法都需要开发者更多地参与到数据分布的过程中,而且需要考虑负载均衡、故障处理等方面的问题。Redis Cluster的引入显著简化了这个过程,通过哈希槽的自动分配和迁移,使得集群管理变得更加自动化和容错。这样,开发者可以更专注于应用的业务逻辑而不必过多地担心底层的分布式数据存储细节。

8、AtomicInteger底层原理

变量value用volatile修饰,保证了多线程之间的内存可见性

2023面试知识点二_第1张图片

Unsafe是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(Native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定的内存数据。Unsafe类存在sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存,因为Java中的CAS操作的执行依赖于Unsafe类的方法。

注意Unsafe类的所有方法都是native修饰的,也就是说unsafe类中的方法都直接调用操作系统底层资源执行相应的任务

为什么Atomic修饰的包装类,能够保证原子性,依靠的就是底层的unsafe类
2023面试知识点二_第2张图片

valueOffset表示该变量值在内存中的偏移地址,因为Unsafe就是根据内存偏移地址获取数据的。

从这里我们能够看到,通过valueOffset,直接通过内存地址,获取到值,然后进行加1的操作
2023面试知识点二_第3张图片

var5:就是我们从主内存中拷贝到工作内存中的值(每次都要从主内存拿到最新的值到自己的本地内存,然后执行compareAndSwapInt()在再和主内存的值进行比较。因为线程不可以直接越过高速缓存,直接操作主内存,所以执行上述方法需要比较一次,在执行加1操作)

那么操作的时候,需要比较工作内存中的值,和主内存中的值进行比较

假设执行 compareAndSwapInt返回false,那么就一直执行 while方法,直到期望的值和真实值一样

  • val1:AtomicInteger对象本身
  • var2:该对象值得引用地址
  • var4:需要变动的数量
  • var5:用var1和var2找到的内存中的真实值
    • 用该对象当前的值与var5比较
    • 如果相同,更新var5 + var4 并返回true
    • 如果不同,继续取值然后再比较,直到更新完成

这里没有用synchronized,而用CAS,这样提高了并发性,也能够实现一致性,是因为每个线程进来后,进入的do while循环,然后不断的获取内存中的值,判断是否为最新,然后在进行更新操作。

假设线程A和线程B同时执行getAndInt操作(分别跑在不同的CPU上)

  1. AtomicInteger里面的value原始值为3,即主内存中AtomicInteger的 value 为3,根据JMM模型,线程A和线程B各自持有一份价值为3的副本,分别存储在各自的工作内存
  2. 线程A通过getIntVolatile(var1 , var2) 拿到value值3,这是线程A被挂起(该线程失去CPU执行权)
  3. 线程B也通过getIntVolatile(var1, var2)方法获取到value值也是3,此时刚好线程B没有被挂起,并执行了compareAndSwapInt方法,比较内存的值也是3,成功修改内存值为4,线程B打完收工,一切OK
  4. 这是线程A恢复,执行CAS方法,比较发现自己手里的数字3和主内存中的数字4不一致,说明该值已经被其它线程抢先一步修改过了,那么A线程本次修改失败,只能够重新读取后在来一遍了,也就是在执行do while
  5. 线程A重新获取value值,因为变量value被volatile修饰,所以其它线程对它的修改,线程A总能够看到,线程A继续执行compareAndSwapInt进行比较替换,直到成功。

Unsafe类 + CAS思想: 也就是自旋

底层汇编

Unsafe类中的compareAndSwapInt是一个本地方法,该方法的实现位于unsafe.cpp中

  • 先想办法拿到变量value在内存中的地址
  • 通过Atomic::cmpxchg实现比较替换,其中参数X是即将更新的值,参数e是原内存的值

CAS缺点

CAS不加锁,保证一次性,但是需要多次比较

  • 循环时间长,开销大(因为执行的是do while,如果比较不成功一直在循环,最差的情况,就是某个线程一直取到的值和预期值不一样,这样就会无限循环)
  • 只能保证一个共享变量的原子操作
    • 当对一个共享变量执行操作时,我们可以通过循环CAS的方式来保证原子操作
    • 但是对于多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候只能用锁来保证原子性
  • ABA问题

9、原子引用

原子引用其实和原子包装类是差不多的概念,就是将一个java类,用原子引用类进行包装起来,那么这个类就具备了原子性

class User {
    String userName;
    int age;

    public User(String userName, int age) {
        this.userName = userName;
        this.age = age;
    }

    public String getUserName() {
        return userName;
    }

    public void setUserName(String userName) {
        this.userName = userName;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    @Override
    public String toString() {
        return "User{" +
                "userName='" + userName + '\'' +
                ", age=" + age +
                '}';
    }
}
public class AtomicReferenceDemo {

    public static void main(String[] args) {

        User z3 = new User("z3", 22);

        User l4 = new User("l4", 25);

        // 创建原子引用包装类
        AtomicReference atomicReference = new AtomicReference<>();

        // 现在主物理内存的共享变量,为z3
        atomicReference.set(z3);

        // 比较并交换,如果现在主物理内存的值为z3,那么交换成l4
        System.out.println(atomicReference.compareAndSet(z3, l4) + "\t " + atomicReference.get().toString());

        // 比较并交换,现在主物理内存的值是l4了,但是预期为z3,因此交换失败
        System.out.println(atomicReference.compareAndSet(z3, l4) + "\t " + atomicReference.get().toString());
    }
}

AtomicStampedReference

时间戳原子引用,来这里应用于版本号的更新,也就是每次更新的时候,需要比较期望值和当前值,以及期望版本号和当前版本号

public class ABADemo {

    /**
     * 普通的原子引用包装类
     */
    static AtomicReference atomicReference = new AtomicReference<>(100);

    // 传递两个值,一个是初始值,一个是初始版本号
    static AtomicStampedReference atomicStampedReference = new AtomicStampedReference<>(100, 1);

    public static void main(String[] args) {

        System.out.println("============以下是ABA问题的产生==========");

        new Thread(() -> {
            // 把100 改成 101 然后在改成100,也就是ABA
            atomicReference.compareAndSet(100, 101);
            atomicReference.compareAndSet(101, 100);
        }, "t1").start();

        new Thread(() -> {
            try {
                // 睡眠一秒,保证t1线程,完成了ABA操作
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 把100 改成 101 然后在改成100,也就是ABA
            System.out.println(atomicReference.compareAndSet(100, 2019) + "\t" + atomicReference.get());

        }, "t2").start();

        System.out.println("============以下是ABA问题的解决==========");

        new Thread(() -> {

            // 获取版本号
            int stamp = atomicStampedReference.getStamp();
            System.out.println(Thread.currentThread().getName() + "\t 第一次版本号" + stamp);

            // 暂停t3一秒钟
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            // 传入4个值,期望值,更新值,期望版本号,更新版本号
            atomicStampedReference.compareAndSet(100, 101, atomicStampedReference.getStamp(), atomicStampedReference.getStamp()+1);

            System.out.println(Thread.currentThread().getName() + "\t 第二次版本号" + atomicStampedReference.getStamp());

            atomicStampedReference.compareAndSet(101, 100, atomicStampedReference.getStamp(), atomicStampedReference.getStamp()+1);

            System.out.println(Thread.currentThread().getName() + "\t 第三次版本号" + atomicStampedReference.getStamp());

        }, "t3").start();

        new Thread(() -> {

            // 获取版本号
            int stamp = atomicStampedReference.getStamp();
            System.out.println(Thread.currentThread().getName() + "\t 第一次版本号" + stamp);

            // 暂停t4 3秒钟,保证t3线程也进行一次ABA问题
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            boolean result = atomicStampedReference.compareAndSet(100, 2019, stamp, stamp+1);

            System.out.println(Thread.currentThread().getName() + "\t 修改成功否:" + result + "\t 当前最新实际版本号:" + atomicStampedReference.getStamp());

            System.out.println(Thread.currentThread().getName() + "\t 当前实际最新值" + atomicStampedReference.getReference());


        }, "t4").start();

    }
}

我们能够发现,线程t3,在进行ABA操作后,版本号变更成了3,而线程t4在进行操作的时候,就出现操作失败了,因为版本号和当初拿到的不一样

LongAdder(CAS机制优化)

LongAdder是java8为我们提供的新的类,跟AtomicLong有相同的效果。是对CAS机制的优化

LongAdder:
//变量声明
public static LongAdder count = new LongAdder();
//变量操作
count.increment();
//变量取值
count

为什么有了AtomicLong还要新增一个LongAdder呢?

原因是:CAS底层实现是在一个死循环中不断地尝试修改目标值,直到修改成功。如果竞争不激烈的时候,修改成功率很高,否则失败率很高。在失败的时候,这些重复的原子性操作会耗费性能。(不停的自旋,进入一个无限重复的循环中)

于是,Java 8推出了一个新的类,LongAdder,他就是尝试使用分段CAS以及自动分段迁移的方式来大幅度提升多线程高并发执行CAS操作的性能!

10、synchronized 和 lock 区别

1)synchronized属于JVM层面,属于java的关键字

  • monitorenter(底层是通过monitor对象来完成,其实wait/notify等方法也依赖于monitor对象 只能在同步块或者方法中才能调用 wait/ notify等方法)
  • Lock是具体类(java.util.concurrent.locks.Lock)是api层面的锁

2)使用方法:

  • synchronized:不需要用户去手动释放锁,当synchronized代码执行后,系统会自动让线程释放对锁的占用
  • ReentrantLock:则需要用户去手动释放锁,若没有主动释放锁,就有可能出现死锁的现象,需要lock() 和 unlock() 配置try catch语句来完成

3)等待是否中断

  • synchronized:不可中断,除非抛出异常或者正常运行完成
  • ReentrantLock:可中断,可以设置超时方法
    • 设置超时方法,trylock(long timeout, TimeUnit unit)
    • lockInterrupible() 放代码块中,调用interrupt() 方法可以中断

4)加锁是否公平

  • synchronized:非公平锁
  • ReentrantLock:默认非公平锁,构造函数可以传递boolean值,true为公平锁,false为非公平锁

5)锁绑定多个条件Condition

  • synchronized:没有,要么随机,要么全部唤醒
  • ReentrantLock:用来实现分组唤醒需要唤醒的线程,可以精确唤醒,而不是像synchronized那样,要么随机,要么全部唤醒
class ShareResource {
    // A 1   B 2   c 3
    private int number = 1;
    // 创建一个重入锁
    private Lock lock = new ReentrantLock();

    // 这三个相当于备用钥匙
    private Condition condition1 = lock.newCondition();
    private Condition condition2 = lock.newCondition();
    private Condition condition3 = lock.newCondition();


    public void print5() {
        lock.lock();
        try {
            // 判断
            while(number != 1) {
                // 不等于1,需要等待
                condition1.await();
            }

            // 干活
            for (int i = 0; i < 5; i++) {
                System.out.println(Thread.currentThread().getName() + "\t " + number + "\t" + i);
            }

            // 唤醒 (干完活后,需要通知B线程执行)
            number = 2;
            // 通知2号去干活了
            condition2.signal();

        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public void print10() {
        lock.lock();
        try {
            // 判断
            while(number != 2) {
                // 不等于2,需要等待
                condition2.await();
            }

            // 干活
            for (int i = 0; i < 10; i++) {
                System.out.println(Thread.currentThread().getName() + "\t " + number + "\t" + i);
            }

            // 唤醒 (干完活后,需要通知C线程执行)
            number = 3;
            // 通知2号去干活了
            condition3.signal();

        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public void print15() {
        lock.lock();
        try {
            // 判断
            while(number != 3) {
                // 不等于3,需要等待
                condition3.await();
            }

            // 干活
            for (int i = 0; i < 15; i++) {
                System.out.println(Thread.currentThread().getName() + "\t " + number + "\t" + i);
            }

            // 唤醒 (干完活后,需要通知C线程执行)
            number = 1;
            // 通知1号去干活了
            condition1.signal();

        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

public class SyncAndReentrantLockDemo {

    public static void main(String[] args) {

        ShareResource shareResource = new ShareResource();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                    shareResource.print5();
            }
        }, "A").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                shareResource.print10();
            }
        }, "B").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                shareResource.print15();
            }
        }, "C").start();
    }
}

11、生产环境线程池使用

根据阿里巴巴手册:并发控制这章

  • 线程资源必须通过线程池提供,不允许在应用中自行显式创建线程
    • 使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源的开销,解决资源不足的问题,如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题
  • 线程池不允许使用Executors去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险

因为默认的Executors创建的线程池,底层都是使用LinkBlockingQueue作为阻塞队列的,而LinkBlockingQueue虽然是有界的,但是它的界限是 Integer.MAX_VALUE 大概有20多亿,可以相当是无界的了,因此我们要使用ThreadPoolExecutor自己手动创建线程池,然后指定阻塞队列的大小

生产环境中配置 corePoolSize 和 maximumPoolSize

这个是根据具体业务来配置的,分为CPU密集型和IO密集型

  • CPU密集型

CPU密集的意思是该任务需要大量的运算,而没有阻塞,CPU一直全速运行

CPU密集任务只有在真正的多核CPU上才可能得到加速(通过多线程)

而在单核CPU上,无论你开几个模拟的多线程该任务都不可能得到加速,因为CPU总的运算能力就那些

CPU密集型任务配置尽可能少的线程数量:

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

  • IO密集型

由于IO密集型任务线程并不是一直在执行任务,则可能多的线程,如 CPU核数 * 2

IO密集型,即该任务需要大量的IO操作,即大量的阻塞

在单线程上运行IO密集型的任务会导致浪费大量的CPU运算能力花费在等待上

所以IO密集型任务中使用多线程可以大大的加速程序的运行,即使在单核CPU上,这种加速主要就是利用了被浪费掉的阻塞时间。

IO密集时,大部分线程都被阻塞,故需要多配置线程数

12、如何排查死锁

当我们出现死锁的时候,首先需要使用jps命令查看运行的程序

jps -l

在使用jstack查看堆栈信息

jstack  7560   # 后面参数是 jps输出的该类的pid

如果进程存在死锁,可以看到 Found 1 deadlock

13、类加载器

2023面试知识点二_第4张图片

13.1、类加载器是什么

JVM 中内置了三个重要的 ClassLoader:

BootstrapClassLoader(启动类加载器) :最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库( /lib目录下的 rt.jar 、resources.jar 、charsets.jar等 jar 包和类))以及被 -Xbootclasspath参数指定的路径下的所有类。

ExtensionClassLoader(扩展类加载器) :主要负责加载 /lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。

AppClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。
2023面试知识点二_第5张图片

除了 BootstrapClassLoader 是 JVM 自身的一部分之外,其他所有的类加载器都是在 JVM 外部实现的,并且全都继承自 ClassLoader抽象类。这样做的好处是用户

可以自定义类加载器,以便让应用程序自己决定如何去获取所需的类。

每个 ClassLoader 可以通过getParent()获取其父 ClassLoader,如果获取到 ClassLoader 为null的话,那么该类是通过 BootstrapClassLoader 加载的。( 这是因

为BootstrapClassLoader 由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,所以拿到的结果是 null。)

13.2、双亲委派机制

如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完

成类加载任务,就成功返回。只有父类加载器无法完成此加载任务时,才自己去加载。

本质是规定类加载的顺序是:引导类加载器先加载,若加载不到,由扩展类加载器加载,若还加载不到,才会由系统类加载器或自定义的类加载器进行加载。

双亲委派机制优势

避免类的重复加载,确保一个类的全局唯一性。

Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子classLoader再加载一次。

保护程序安全,防止核心API被随意算改

如果定义一个和String相同包的类java.lang.String,在运行时会抛出java.lang.SecurityException异常,起到了保护核心API的作用。

双亲委派机制劣势

检查类是否加载的委托过程是单向的,这个方式虽然从结构上说比较清晰,使各个ClassLoader的职责非常明确,但是同时会带来一个问题,即顶层的

ClassLoader无法访问底层的ClassLoader所加载的类。

通常情况下,启动类加载器中的类为系统核心类,包括一些重要的系统接口,而在应用类加载器中,为应用类。按照这种模式,应用类访问系统类自然是没有问

题,但是系统类访问应用类就会出现问题。比如在系统类中提供了一个接口,该接口需要在应用类中得以实现,该接口还绑定一个工厂方法,用于创建该接口的实

例,而接口和工厂方法都在启动类加载器中。这时,就会出现该工厂方法无法创建由应用类加载器加载的应用实例的问题。

源码逻辑

双亲委派机制在java.lang.ClassLoader.loadClass(String,boolean)接口中体现。该接口的逻辑如下:

(1)先在当前加载器的缓存中查找有无目标类,如果有,直接返回。

(2)判断当前加载器的父加载器是否为空,如果不为空,则调用parent.loadclass(name,false)接口进行加载。

(3)反之,如果当前加载器的父类加载器为空,则调用findBootstrapclassOrNull(name)接口,让引导类加载器进行加载。

(4)如果通过以上3条路径都没能成功加载,则调用findclass(name)接口进行加载。该接口最终会调用java.lang.ClassLoader接口的

defineClass系列的native接口加载目标Java类。双亲委派的模型就隐藏在这第2和第3步中。

破坏双亲委派机制----线程上下文类加载器

双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的,双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器

进行加载》,基础类型之所以被称为“基础”,是因为它们总是作为被用户代码继承、调用的API存在,但程序设计往往没有绝对不变的完美规则,如果有基础类型又

要调用回用户的代码,那该怎么办呢?

这并非是不可能出现的事情,一个典型的例子便是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器来完成加载(在JDK 1.3时加入到rt.jar的),

肯定属于Java中很基础的类型了。但JNDI存在的目的就是对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提

供者接口(Service Provider Interface,SPI)的代码,现在问题来了,启动类加载器是绝不可能认识、加载这些代码的,那孩怎么办?SPI:在Java平台中,通常把核心

类rt.ar中提供外部服务、可由应用层自行实现的接口称为SPI)

为了解决这个困境,Java的设计团队只好引入了一个不太优雅的设计: 线程上下文类加载器 (Thread ContextlassLoader)。这个类加载器可以通过iava,lang,Thread

类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类

加载器默认就是应用程序类加载器。

13.3、Java类加载的沙箱安全机制

自定义string类,但是在加载自定义string类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会优先加载jdk自带的文件(rt.jar包中的

java\lang\string),报错信息中说没有main方法。就是因为加载的是rt.jar中的string类。这样就可以保证对java核心源代码的保护,这就是沙箱安全机制。

14、GC Roots

那些对象可以当做GC Roots

  • 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中的引用对象
  • 方法区中的类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中的JNI(Native方法)的引用对象

/**
 * 在Java中,可以作为GC Roots的对象有:
 * - 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中的引用对象
 * - 方法区中的类静态属性引用的对象
 * - 方法区中常量引用的对象
 * - 本地方法栈中的JNI(Native方法)的引用对象
 */
public class GCRootDemo {


    // 方法区中的类静态属性引用的对象
    // private static GCRootDemo2 t2;

    // 方法区中的常量引用,GC Roots 也会以这个为起点,进行遍历
    // private static final GCRootDemo3 t3 = new GCRootDemo3(8);

    public static void m1() {
        // 第一种,虚拟机栈中的引用对象
        GCRootDemo t1 = new GCRootDemo();
        System.gc();
        System.out.println("第一次GC完成");
    }
    public static void main(String[] args) {
        m1();
    }
}

15、jvm参数调优

你说你做过JVM调优和参数配置,请问如何盘点查看JVM系统默认值

使用jps和jinfo进行查看

-Xms:初始堆空间
-Xmx:堆最大值
-Xss:栈空间

Xms 和 -Xmx最好调整一致,防止JVM频繁进行收集和回收

JVM参数类型

  • 标配参数(从JDK1.0 - Java12都在,很稳定)
    • -version
    • -help
    • java -showversion
  • X参数(了解)
    • -Xint:解释执行
    • -Xcomp:第一次使用就编译成本地代码
    • -Xmixed:混合模式
  • XX参数(重点)
    • Boolean类型
      • 公式:-XX:+ 或者-某个属性 + 表示开启,-表示关闭
      • Case:-XX:-PrintGCDetails:表示关闭了GC详情输出
    • key-value类型
      • 公式:-XX:属性key=属性value
      • 不满意初始值,可以通过下列命令调整
      • case:如何:-XX:MetaspaceSize=21807104:查看Java元空间的值

查看运行的Java程序,JVM参数是否开启,具体值为多少?

首先我们运行一个HelloGC的java程序

/**
 * @author: 陌溪
 * @create: 2020-03-19-12:14
 */
public class HelloGC {

    public static void main(String[] args) throws InterruptedException {
        System.out.println("hello GC");
        Thread.sleep(Integer.MAX_VALUE);
    }
}

然后使用下列命令查看它的默认参数

jps:查看java的后台进程
jinfo:查看正在运行的java程序

具体使用:

jps得到进程号

jinfo pid查看java程序的JVM参数

jinfo 15203

jinfo -flag查看是否开启PrintGCDetails这个参数

jinfo -flag PrintGCDetails 15203

得到的内容为

-XX:-PrintGCDetails

上面提到了,-号表示关闭,即没有开启PrintGCDetails这个参数

在VM Options中加入下面的代码,现在+号表示开启

-XX:+PrintGCDetails

然后在使用jinfo查看我们的配置

jps
jinfo -flag PrintGCDetails 15203

得到的结果为

-XX:+PrintGCDetails

我们看到原来的-号变成了+号,说明我们通过 VM Options配置的JVM参数已经生效了

题外话(坑题)

两个经典参数:-Xms 和 -Xmx,这两个参数 如何解释

这两个参数,还是属于XX参数,因为取了别名

  • -Xms 等价于 -XX:InitialHeapSize :初始化堆内存(默认只会用最大物理内存的64分1)
  • -Xmx 等价于 -XX:MaxHeapSize :最大堆内存(默认只会用最大物理内存的4分1)

查看JVM默认参数

  • -XX:+PrintFlagsInitial

    • 主要是查看初始默认值

    • 公式

      java -XX:+PrintFlagsInitial -version
      
  • -XX:+PrintFlagsFinal:表示修改以后,最终的值

会将JVM的各个结果都进行打印

如果有 := 表示修改过的, = 表示没有修改过的

生活常用调优参数

  • -Xms:初始化堆内存,默认为物理内存的1/64,等价于 -XX:initialHeapSize
  • -Xmx:最大堆内存,默认为物理内存的1/4,等价于-XX:MaxHeapSize
  • -Xss:设计单个线程栈的大小,一般默认为512K~1024K,等价于 -XX:ThreadStackSize
    • 使用 jinfo -flag ThreadStackSize 会发现 -XX:ThreadStackSize = 0
    • 这个值的大小是取决于平台的
    • Linux/x64:1024KB
    • OS X:1024KB
    • Oracle Solaris:1024KB
    • Windows:取决于虚拟内存的大小
  • -Xmn:设置年轻代大小
  • -XX:MetaspaceSize:设置元空间大小
    • 元空间的本质和永久代类似,都是对JVM规范中方法区的实现,不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存,因此,默认情况下,元空间的大小仅受本地内存限制。
    • -Xms10m -Xmx10m -XX:MetaspaceSize=1024m -XX:+PrintFlagsFinal
    • 但是默认的元空间大小:只有20多M
    • 为了防止在频繁的实例化对象的时候,让元空间出现OOM,因此可以把元空间设置的大一些
  • -XX:PrintGCDetails:输出详细GC收集日志信息
    • GC
    • Full GC

GC日志收集流程图

2023面试知识点二_第6张图片

Full GC垃圾回收

Full GC大部分发生在养老区
2023面试知识点二_第7张图片

规律:

[名称: GC前内存占用 -> GC后内存占用 (该区内存总大小)]

当我们出现了老年代都扛不住的时候,就会出现OOM异常

-XX:SurvivorRatio

调节新生代中 eden 和 S0、S1的空间比例,默认为 -XX:SuriviorRatio=8,Eden:S0:S1 = 8:1:1

加入设置成 -XX:SurvivorRatio=4,则为 Eden:S0:S1 = 4:1:1

SurvivorRatio值就是设置eden区的比例占多少,S0和S1相同

Java堆从GC的角度还可以细分为:新生代(Eden区,From Survivor区合To Survivor区)和老年代

  • eden、SurvivorFrom复制到SurvivorTo,年龄 + 1

首先,当Eden区满的时候会触发第一次GC,把还活着的对象拷贝到SurvivorFrom去,当Eden区再次触发GC的时候会扫描Eden区合From区域,对这两个区域进

行垃圾回收,经过这次回收后还存活的对象,则直接复制到To区域(如果对象的年龄已经到达老年的标准,则赋值到老年代区),通知把这些对象的年龄 + 1

  • 清空eden、SurvivorFrom

然后,清空eden,SurvivorFrom中的对象,也即复制之后有交换,谁空谁是to

  • SurvivorTo和SurvivorFrom互换

最后,SurvivorTo和SurvivorFrom互换,原SurvivorTo成为下一次GC时的SurvivorFrom区,部分对象会在From和To区域中复制来复制去,如此交换15次(由

JVM参数MaxTenuringThreshold决定,这个参数默认为15),最终如果还是存活,就存入老年代

-XX:NewRatio(了解)

配置年轻代new 和老年代old 在堆结构的占比

默认: -XX:NewRatio=2 新生代占1,老年代2,年轻代占整个堆的1/3

-XX:NewRatio=4:新生代占1,老年代占4,年轻代占整个堆的1/5,NewRadio值就是设置老年代的占比,剩下的1个新生代

新生代特别小,会造成频繁的进行GC收集

-XX:MaxTenuringThreshold

设置垃圾最大年龄,SurvivorTo和SurvivorFrom互换,原SurvivorTo成为下一次GC时的SurvivorFrom区,部分对象会在From和To区域中复制来复制去,如此交

换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认为15),最终如果还是存活,就存入老年代

这里就是调整这个次数的,默认是15,并且设置的值 在 0~15之间

查看默认进入老年代年龄:jinfo -flag MaxTenuringThreshold 17344

-XX:MaxTenuringThreshold=0:设置垃圾最大年龄。如果设置为0的话,则年轻对象不经过Survivor区,直接进入老年代。对于年老代比较多的应用,可以提高效

率。如果将此值设置为一个较大的值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概率

你可能感兴趣的:(java面试,面试,职场和发展,kubernetes,jvm)