Java 春招、秋招面试总结

1. final的作用是什么

  • 修饰基本数据类型,该值初始化后无法修改

  • 修饰引用数据类型,例如:数组、对象,该值的引用地址不能修改,但是对象和数组本身可以修改

    如果引用类型的值和地址都需能修改可以使用Collections包下的unmodifiable…方法

  • 修饰方法,该方法不能被重写,但是可以重载

  • 修饰类,该类不能被继承

2. Integer和int区别

  • Integer类中有一个类似缓存数组的东西,数组里面会提前缓存好 -128 到 127 之间的数字,如果通过Integer创建的数字在这个区间之内,直接用数组里面的缓存,如果大于这个区间,调用new Integer()创建

  • 包装类型(Integer)是可以为null的,所以可以应用于pojo中,平时定义的基本类通过数据库查询结果可能为null,如果使用int类型修饰,可能会导致抛出异常

  • 基本类型更高效,因为基本类型在栈中直接存储具体数值,包装类型存储堆引用

  • 包装类型可以用于泛型,基本类型不可以

    List<int> list = new ArrayList<>();
     // 提示 Syntax error, insert "Dimensions" to complete ReferenceType
    List<Integer> list = new ArrayList<>();
    

3. 抽象类和接口的区别

  • 抽象类只能单继承,接口可以多实现。

  • 抽象类可以有构造方法,接口中不能有构造方法。

  • 抽象类中可以有成员变量,接口中没有成员变量,只能有常量(默认就是 public static final)

  • 抽象类中可以包含非抽象的方法,在 Java 7 之前接口中的所有方法都是抽象的,在 Java 8 之后,接口支持非抽象方法:default 方法、静态方法等。Java 9 支持私有方法、私有静态方法。

  • 抽象类中的抽象方法类型可以是任意修饰符,Java 8 之前接口中的方法只能是 public 类型,Java 9 支持 private 类型。

4. java中反射是什么

  • 平时使用某个类时,找到这个类,之后通过new实例化创建对象使用对象去对类进行操作,反射就是,我不知道使用的是什么,通过JDK的反射API实现实例化对象,运行时才知道用的是什么,通过class、constructor、field、method四个方法获取一个类的各个组成部分,简单的工厂模式用到了反射、IOC也用到了反射

5. throw和throws区别

  • throw

    作用在方法内,表示抛出具体异常,由方法体内的语句处理,和try catch一起

  • throws

    作用在方法的声明上,表示抛出异常,由调用者来进行异常处理,可能出现异常,不一定会发生异常

6. finally和finalize

  • finally和try catch结合,通常用于一些流的关闭(输入输出)

  • finalize方法用于垃圾回收。

    当调用finalize方法后,并不意味着gc会立即回收该对象,所以有可能真正调用的时候,对象又不需要回收了,然后到了真正要回收的时候,因为之前调用过一次,这次又不会调用了,产生问题。所以,不推荐使用finalize方法。

7. 静态方法中能访问非静态变量?

  • 不能,因为静态变量和方法属于类本身,在类加载的时候就会分配内存,可以通过类名直接访问,非静态变量属于类的对象,只有在类的对象产生时,才会分配内存,通过类的实例去访问,静态方法也属于类本身,但是此时没有类的实例,内存中没有非静态变量,所以无法调用

8. String str="i"与 String str=new String(“i”)一样吗?

  • String str="i"会将起分配到常量池中,常量池中没有重复的元素,如果常量池中存中i,就将i的地址赋给变量,如果没有就创建一个再赋给变量。

  • String str=new String(“i”)会将对象分配到堆中,即使内存一样,还是会重新创建一个新的对象

9. java集合框架图

在这里插入图片描述

10. Collection 是什么 Collections 又是什么

  • Collection是最基本的集合接口,Collection派生了两个子接口list和set,分别定义了两种不同的存储方式

  • Collections是一个包装类,它包含各种有关集合操作的静态方法(对集合的搜索、排序、线程安全化等)

    此类不能实例化,就像一个工具类,服务于Collection框架

11. List、Set 区别

  • List:一个有序(元素存入集合的顺序和取出的顺序一致)容器,元素可以重复,可以插入多个null元素,元素都有索引。常用的实现类有 ArrayList、LinkedList 和 Vector
  • Set:一个无序(存入和取出顺序有可能不一致)容器,不可以存储重复元素,只允许存入一个null元素,必须保证元素唯一性。Set 接口常用实现类是 HashSet、LinkedHashSet 以及 TreeSet

12. ArrayList 和 LinkedList的区别

  • ArrayList 底层使用的是Object数组,适合查找和遍历,但是在ArrayList存在一定的空间浪费,因为List列表会在末尾预留一些空间防止在进行插入数据时频繁的请求内存
  • LinkedList 底层使用的数据结构是双向链表,适合插入和删除,但是LinkedList每个节点都会存储他指向 前驱节点 和 后继结点 的指针,其中的每个元素消耗的内存空间比ArrayList更大

13. Map集合

(1)HashMap

  • 简介:HashMap基于map接口,元素以键值对方式存储,允许有null值(key可以为空但是只能有一个为空Key,value随便),HashMap是线程不安全的

  • 底层数据结构:

    • JDK1.7中采用数组+链表的存储形式

      HashMap采取Entry数组来存储key-value,每一个键值对组成了一个Entry实体,Entry类时机上是一个单向的链表结构,它具有next指针,指向下一个Entry实体,以此来解决Hash冲突的问题。HashMap实现一个内部类Entry,重要的属性有hash、key、value、next
      在这里插入图片描述

    • JDK1.8中采用数据+链表+红黑树的存储形式,当链表长度超过阈值(8)时,将链表转换为红黑树。在性能上进一步得到提升
      在这里插入图片描述
      链表——红黑树 转换 首先会判断同一个hashcode下面插入数据是否 >= 7,如果大于7会调用treeifyBin方法:如果数组长度不满足转换成树的最小长度,先进行数组扩容(因为数组扩容也就变向的减小了链表的长度),如果满足条件,会先将单向node节点链表转成双向treeNode节点链表,然后调用treeify方法

      treeifyBin:

       final void treeifyBin(Node<K,V>[] tab, int hash) {
           int n, index; Node<K,V> e;
           if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
               resize();
           else if ((e = tab[index = (n - 1) & hash]) != null) {
               TreeNode<K,V> hd = null, tl = null;
               do {
                   TreeNode<K,V> p = replacementTreeNode(e, null);
                   if (tl == null)
                       hd = p;
                   else {
                       p.prev = tl;
                       tl.next = p;
                   }
                   tl = p;
               } while ((e = e.next) != null);
               if ((tab[index] = hd) != null)
                   hd.treeify(tab);
           }
       }
      
      

      treeify:

        final void treeify(Node<K,V>[] tab) {
            TreeNode<K,V> root = null;
            // 遍历链表节点
            for (TreeNode<K,V> x = this, next; x != null; x = next) {
                next = (TreeNode<K,V>)x.next;
                x.left = x.right = null;
                if (root == null) {
                    x.parent = null;
                    x.red = false;
                    root = x;
                }
                else {
                    K k = x.key;
                    int h = x.hash;
                    Class<?> kc = null;
                    // 遍历树节点
                    for (TreeNode<K,V> p = root;;) {
                        int dir, ph; 
                        K pk = p.key;
                        // 通过key和value比较得到节点应该放在树的左面还是右面
                        if ((ph = p.hash) > h)
                            dir = -1;
                        else if (ph < h)
                            dir = 1;
                        else if ((kc == null &&
                                  (kc = comparableClassFor(k)) == null) ||
                                 (dir = compareComparables(kc, k, pk)) == 0)
                            dir = tieBreakOrder(k, pk);
      
                        TreeNode<K,V> xp = p;
                        if ((p = (dir <= 0) ? p.left : p.right) == null) {
                            x.parent = xp;
                            if (dir <= 0)
                                xp.left = x;
                            else
                                xp.right = x;
                            root = balanceInsertion(root, x); // 插入平衡调整
                            break;
                        }
                    }
                }
            }
            moveRootToFront(tab, root); // 将树的根节点指向数组
        }
      
      

(2)ConcurrentHashMap

  • ConcurrentHashMap内部加锁改变

    • **Java 8 之前 **ConcurrentHashMap的原理是引用了内部的 Segment ( ReentrantLock ) 分段锁,保证在操作不同段 map 的时候, 可以并发执行, 操作同段 map 的时候,进行锁的竞争和等待。从而达到线程安全, 且效率大于 synchronized

    • Java 8 之后, JDK 却弃用了这个策略,重新使用了 synchronized+CAS

    • 弃用原因:

      加入多个分段锁浪费内存空间

      生产环境中, map 在放入时竞争同一个锁的概率非常小,分段锁反而会造成更新等操作的长时间等待。

      为了提高 GC 的效率

      新的同步方案

  • ConcurrentHashMap加锁形式

    concurrentHashMap锁的方式是细粒度的。concurrentHashMap将hash分为16个桶(默认值),诸如get、put、remove等常用操作只锁住当前需要用到的

    桶,concurrentHashMap的读取并发,因为读取的大多数时候都没有锁定,所以读取操作几乎是完全的并发操作,只是在求size时才需要锁定整个hash

(3)HashTable

  • HashTable是线程安全的,主要通过使用synchronized关键字修饰大部分方法,使得每次只能一个线程对HashTable进行同步修改,性能开销较大。

14. hashCode()和equals()

  • 当集合需要添加新的对象时,先调用这个对象的hashcode()方法,得到对应的hashcode值,实际上hashmap中会有一个table保存已经存进去的对象的

    hashcode值,如果table中没有改hashcode值,则直接存入,如果有,就调用equals方法与新元素进行比较,相同就不存了,不同就存入。

  • hashCode和equals应该一起重写,

    • 假如只重写equals()方法(结果:相同的对象hashCode不同,从而映射到不同下标下,HashMap无法保证去重)
    • 假如只重写hashCode()方法(结果:HashMap可以存在两个内存地址不相同,但是相等的对象,无法保证去重)

15. CAS 和 Synchronized

  • CAS(compare and swap),包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B),如果内存位置的值与预期原值相匹配,那么处理器会自动将该

    位置值更新为新值 ,否则,处理器不做任何操作,无论哪种情况,它都会在 CAS 指令之前返回该位置的值,在使用CAS时会可能产生ABA问题

    • ABA问题:我要去银行取钱 我有存款一百元 我现在要去取款五十元,但是取款机硬件出现了问题,我提交了两遍,也就是说 我开起了两个线程 两个线程都是要 获取到100元 然后更改为50元,但是在执行的时候,有一个线程由于未知原因进入了阻塞状态,有一个线程成功了,这个时候余额被改为了50元,就在这时,有人又给我汇款了五十块钱,我的余额又变成了100块钱。然后刚才被阻塞的线程又恢复了,compare到余额还是一百,所以又执行成功了 将余额改为了五十。 本来阻塞的那个线程应该是失败的,我的余额应该是100块钱,现在因为ABA问题导致成功执行了。
  • synchronized

    • 可重入性

      synchronized的锁对象中有一个计数器(recursions变量)会记录线程获得几次锁;

      可重入的好处:可以避免死锁;可以让我们更好的封装代码;
      synchronized是可重入锁,每部锁对象会有一个计数器记录线程获取几次锁,在执行完同步代码块时,计数器的数量会-1,直到计数器的数量为0,就释放这个锁。

    • 不可中断性

    • 一个线程获得锁后,另一个线程想要获得锁,必须处于阻塞或等待状态,如果第一个线程不释放锁,第二个线程会一直阻塞或等待,不可被中断;
      synchronized 属于不可被中断;

16. volatile 关键字?

  • 对于可见性,Java 提供了 volatile 关键字来保证可见性和禁止指令重排。 volatile 提供 happens-before 的保证,确保一个线程的修改能对其他线程是可见的。当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

    从实践角度而言,volatile 的一个重要作用就是和 CAS 结合,保证了原子性,详细的可以参见 java.util.concurrent.atomic 包下的类,比如 AtomicInteger。

    volatile 常用于多线程环境下的单次操作(单次读或者单次写)。

17. ReentrantLock 是如何实现可重入性的?

  • 什么是可重入性

    一个线程持有锁时,当其他线程尝试获取该锁时,会被阻塞;而这个线程尝试获取自己持有锁时,如果成功说明该锁是可重入的,反之则不可重入。

  • ReentrantLock如何实现可重入性

    ReentrantLock使用内部类Sync来管理锁,所以真正的获取锁是由Sync的实现类控制的。Sync有两个实现,分别为NonfairSync(非公公平锁)和FairSync(公平锁)。Sync通过继承AQS实现,在AQS中维护了一个private volatile int state来计算重入次数,避免频繁的持有释放操作带来的线程问题当一个线程在获取锁过程中,先判断state的值是否为0,如果是表示没有线程持有锁,就可以尝试获取锁。当state的值不为0时,表示锁已经被一个线程占用了,这时会做一个判断current==getExclusiveOwnerThread(),这个方法返回的是当前持有锁的线程,这个判断是看当前持有锁的线程是不是自己,如果是自己,那么将state的值+1,表示重入返回即可。

  • ReentrantLock代码实例

    // Sync继承于AQS
    abstract static class Sync extends AbstractQueuedSynchronizer {
      ...
    }
    // ReentrantLock默认是非公平锁
    public ReentrantLock() {
            sync = new NonfairSync();
     }
    // 可以通过向构造方法中传true来实现公平锁
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }
    
    protected final boolean tryAcquire(int acquires) {
            // 当前想要获取锁的线程
            final Thread current = Thread.currentThread();
            // 当前锁的状态
            int c = getState();
            // state == 0 此时此刻没有线程持有锁
            if (c == 0) {
                // 虽然此时此刻锁是可以用的,但是这是公平锁,既然是公平,就得讲究先来后到,
                // 看看有没有别人在队列中等了半天了
                if (!hasQueuedPredecessors() &&
                    // 如果没有线程在等待,那就用CAS尝试一下,成功了就获取到锁了,
                    // 不成功的话,只能说明一个问题,就在刚刚几乎同一时刻有个线程抢先了 =_=
                    // 因为刚刚还没人的,我判断过了
                    compareAndSetState(0, acquires)) {
     
                    // 到这里就是获取到锁了,标记一下,告诉大家,现在是我占用了锁
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
              // 会进入这个else if分支,说明是重入了,需要操作:state=state+1
            // 这里不存在并发问题
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            // 如果到这里,说明前面的if和else if都没有返回true,说明没有获取到锁
            return false;
        }
    

18. AQS是什么

  • AQS(AbstractQueuedSynchronizer),抽象队列同步器,是一个可以用于实现基于先进先出等待队列的锁和同步器的框架。实现锁 ReentrantLock,CountDownLatch,Semaphore,ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。

  • ReentrantLock的非公平锁与公平锁的区别在于非公平锁在CAS更新state失败后会调用tryAcquire()来判断是否需要进入同步队列,会再次判断state的值是否为0,为0会去CAS更新state值,更新成功就直接获得锁,否则就进入等待队列。(进等待队列之前会抢锁)

    而公平锁首先判断state是否为0,为0并且等待队列为空,才会去使用CAS操作抢占锁,抢占成功就获得锁,没成功并且当前线程不是获得锁的线程,都会被加入到等待队列。

19. 锁升级

在这里插入图片描述

  • 偏向锁

    根据上面的表来看,Mark Word后三位为101时,加锁对象的状态为偏向锁,偏向锁的意义在于同一个线程访问sychronize代码块时不需要进行加锁,解锁操作,性能开销更低(HotSpot[1]的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。)

    因为正常情况下,当一个线程访问同步块并获取轻量级锁时,需要进行CAS操作将对象头的锁记录里指向当前线程的栈中的锁记录,执行完毕后需要释放轻量级锁。如果是同一个线程多次访问sychronize代码块,多次获取和释放轻量级,开销会偏大,所以会一开始判断对象是无锁状态,会将对象头设置为偏向锁,并且这个的线程ID添加到锁对象的Mark Word中,后续同一线程判断加锁标志是偏向锁,并且线程ID一致就可以直接执行。偏向锁的加锁过程:

    场景一:当锁对象第一次被线程获得锁的时候

    线程发现是匿名偏向状态(也就是锁对象的Mark Word没有存储线程ID),则会用CAS指令,将 mark word中的thread id由0改成当前线程Id。如果成功,则代表获得了偏向锁,继续执行同步块中的代码。否则,将偏向锁撤销,升级为轻量级锁。

    场景二:当获取偏向锁的线程再次进入同步块时

    发现锁对象存储的线程ID就是当前线程的ID,会往当前线程的栈中添加一条 DisplacedMarkWord为空的 LockRecord中,然后继续执行同步块的代码,因为操纵的是线程私有的栈,因此不需要用到CAS指令;由此可见偏向锁模式下,当被偏向的线程再次尝试获得锁时,仅仅进行几个简单的操作就可以了,在这种情况下, synchronized关键字带来的性能开销基本可以忽略。

    场景二:当没有获得锁的线程进入同步块时

    当没有获得锁的线程进入同步块时,发现当前是偏向锁状态,并且存储的是其他线程ID(也就是其他线程正在持有偏向锁),则会进入到撤销偏向锁的逻辑里,一般来说,会在 safepoint中去查看偏向的线程是否还存活

    • 如果线程存活且还在同步块中执行, 则将锁升级为轻量级锁,原偏向的线程继续拥有锁,只不过持有的是轻量级锁,继续执行代码块,执行完之后按照轻量级锁的解锁方式进行解锁,而其他线程则进行自旋,尝试获得轻量级锁。
    • 如果偏向的线程已经不存活或者不在同步块中, 则将对象头的 mark word改为无锁状态(unlocked)

    由此可见,偏向锁升级的时机为:当一个线程获得了偏向锁,在执行时,只要有另一个线程尝试获得偏向锁,并且当前持有偏向锁的线程还在同步块中执行,则该偏向锁就会升级成轻量级锁。

  • 轻量级锁

    重量级锁依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的,而在大部分时候可能并没有多线程竞争,只是多个线程交替执行,(例如:这段时间是线程A执行同步块,另外一段时间是线程B来执行同步块,仅仅是多线程交替执行,并不是同时执行,也没有竞争),如果采用重量级锁效率比较低。以及在重量级锁中,没有获得锁的线程会阻塞,获得锁之后线程会被唤醒,阻塞和唤醒的操作是比较耗时间的,如果同步块的代码执行比较快,等待锁的线程可以进行先进行自旋操作(就是不释放CPU,执行一些空指令或者是几次for循环),等待获取锁,这样效率比较高。所以轻量级锁天然瞄准不存在锁竞争的场景,如果存在锁竞争但不激烈,仍然可以用自旋锁优化,自旋失败后再升级为重量级锁。

    轻量级锁的加锁过程

    JVM会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,我们称为Displaced Mark Word。如果一个线程获得锁的时候发现是轻量级锁,会把锁的Mark Word复制到自己的Displaced Mark Word里面。

    然后线程尝试用CAS操作将自己线程栈中拷贝的锁记录的地址写入到锁对象的Mark Word中。如果成功,当前线程获得锁,如果失败,表示Mark Word已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。

    自旋:不断尝试去获取锁,一般用循环来实现。

    自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。

    JDK采用了适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋时触发重量级锁的阀值会更高,如果自旋失败了,则自旋的次数就会减少。

    自旋也不是一直进行下去的,如果自旋到一定程度(和JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁。

  • 重量级锁

    每个对象都有一个监视器monitor对象,重量级锁就是由对象监视器monitor来实现的,当多个线程同时请求某个重量级锁时,重量级锁会设置几种状态用来区分请求的线程:

    Contention List 竞争队列:所有请求锁的线程将被首先放置到该竞争队列,我也不知道为什么网上的文章都叫它队列,其实这个队列是先进后出的,更像是栈,就是当Entry List为空时,Owner线程会直接从Contention List的队列尾部(后加入的线程中)取一个线程,让它成为OnDeck线程去竞争锁。(主要是刚来获取重量级锁的线程是会进行自旋操作来获取锁,获取不到才会进入Contention List,所以OnDeck线程主要与刚进来还在自旋,还没有进入到Contention List的线程竞争)

    Entry List 候选队列:Contention List中那些有资格成为候选人的线程被移到Entry List,主要是为了减少对Contention List的并发访问,因为既会添加新线程到队尾,也会从队尾取线程。

    Wait Set 等待队列:那些调用wait()方法被阻塞的线程被放置到Wait Set。

    OnDeck:任何时刻最多Entry List中只能有一个线程被选中,去竞争锁,该线程称为OnDeck线程。

    Owner:获得锁的线程称为Owner。

    !Owner:释放锁的线程。

20. 进程和线程

  • 进程,操作系统资源分配的最小单位,CPU通过时间片轮转控制各个进程的执行

    • 如果一个进程在时间片结束后还在运行,则暂停这个进程保留当前状态,CPU为其他进程分配时间片(上下文切换),如果一个进程在时间片内结束任务,直接切换为其他线程,不用等时间片结束
    • 每个进程都有单独的内存空间,多以进程之间互不干扰,一个进程出现问题几乎不会影响其他进程,可靠性高
  • 线程,一个进程可以包含多个线程,程序执行流的最小单元,各个线程共享程序内存空间(进程的内存空间),但是如果一个线程出现问题可能会影响程序,可靠性低

21. 线程创建

  • 继承Thread类,重写Run

    class CustomThread extends Thread {
        public static void main(String[] args) {
            ...
        }
        void run() {
            ...
        }
    }
    
  • 实现Runnable接口

    class ThreadTarget implements Runnable {
        void run() {
        	...
        }
        public static void main(String[] args) {
            ThreadTarget target = new ThreadTarget();
            Thread thread = new Thread(target);
            thread.start();
        }
    }
    
  • 实现Callable接口(有返回值)

    public class CallableTarget implements Callable<Integer> {
        public Integer call() throws InterruptedException {
            Thread.sleep(5000);
            return 1;
        }
        public static void main(String[] args) throws ExecutionException, InterruptedException {
            CallableTarget callableTarget = new CallableTarget();
            FutureTask<Integer> task = new FutureTask<Integer>(callableTarget);
            Thread thread = new Thread(task);
            thread.start();
            Integer result = task.get();//当前线程会阻塞,一直等到结果返回。
            System.out.println("执行完毕,打印result="+result);
            System.out.println("执行完毕");
        }
    }
    

22. 懒汉饿汉

  • 懒汉模式,随用随调,不用的时候不会创建实例

    public class Singleton {
        private static Singleton instance;
        public synchronized static Singleton getInstance() {
            if (instance == null)
                instance = new Instance();
            return instance;
        }
    }
    
  • 饿汉模式,在调用之前实例就已经被创建

    public class Singleton {  
        private static Singleton instance = new Singleton();  
        public static Singleton getInstance() {  
            return instance;  
        }  
    } 
    
  • 双重检查

    public class Singleton {               
        private static Singleton instance;              
        public static Singleton getInstance() {              
            if (instance == null) {                        
                synchronized (Singleton.class) { 
                    if (instance == null) { //双重检查存在的意义在于可能会有多个线程进入第一个判断,然后竞争同步锁,线程A得到了同步锁,创建了一个Singleton实例,赋值给instance,然后释放同步锁,此时线程B获得同步锁,又会创建一个Singleton实例,造成初始化覆盖。                
                        instance = new Singleton();        
                    }
                }                                   
            }                                      
            return instance;                        
        }                                                 
    }               
    

23. 为什么我们启动线程需要调用 start() 不能直接调用 run() 方法?

  • new 一个 Thread,线程进入了新建状态。调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。

    而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。

24. wait()、sleep()、yield()、join()了解过吗?

(1)wait

  • 调用wait()方法前线程必须持有对象Object的锁。线程调用wait()方法后,会释放当前的Object锁,进入锁的monitor对象的等待队列,直到有其他线程调用notify()/notifyAll()方法唤醒等待锁的线程。

  • 需要注意的是,其他线程调用notify()方法只会唤醒单个等待锁的线程,如果有多个线程都在等待这个锁的话,不一定会唤醒到之前调用wait()方法的线程。

  • 同样,调用notifyAll()方法唤醒所有等待锁的线程之后,也不一定会马上把时间片分给刚才放弃锁的那个线程,具体要看系统的调度。

(2)sleep()

  • 线程执行 sleep() 方法后进入超时等待(TIMED_WAITING)状态
  • sleep() 方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程运行的机会
  • 在使用sleep()时,不需要考虑唤醒,时间到了自己就醒了

(3)yield()

  • 执行 yield() 方法后进入就绪(READY)状态
  • yield() 方法只会给相同优先级或更高优先级的线程以运行的机会

(4)join()

  • join()方法是Thread类的一个实例方法。它的作用是让当前线程陷入“等待”状态,等join的这个线程threadA执行完成后,再继续执行当前线程。
  • 如果一个线程A执行了 threadB.join() 语句,其含义是:当前线程A等待 threadB 线程终止之后才从 threadB.join() 返回继续往下执行自己的代码。

25. 线程状态图

在这里插入图片描述

  • NEW 新建态

    处于NEW状态的线程此时尚未启动,还没调用Thread实例的start()方法。

  • RUNNABLE 运行态

    表示当前线程正在运行中。处于RUNNABLE状态的线程可能在Java虚拟机中运行,也有可能在等待其他系统资源(比如I/O)。

    Java线程的RUNNABLE状态其实是包括了传统操作系统线程的readyrunning两个状态的。

  • BLOCKED 阻塞态

    阻塞状态。线程没有申请到synchronize同步锁,就会处于阻塞状态,等待锁的释放以进入同步区。

  • WAITING 等待态

    等待状态。处于等待状态的线程变成RUNNABLE状态需要其他线程唤醒。

    调用如下3个方法会使线程进入等待状态:

    • Object.wait():使当前线程处于等待状态直到另一个线程调用notify唤醒它;
    • Thread.join():等待线程执行完毕,底层调用的是Object实例的wait()方法;
    • LockSupport.park():除非获得调用许可,否则禁用当前线程进行线程调度。
  • TIMED_WAITING 超时等待状态

    超时等待状态。线程等待一个具体的时间,时间到后会被自动唤醒。

    调用如下方法会使线程进入超时等待状态:

    • Thread.sleep(long millis):使当前线程睡眠指定时间;
    • Object.wait(long timeout):线程休眠指定时间,等待期间可以通过notify()/notifyAll()唤醒;
    • Thread.join(long millis):等待当前线程最多执行millis毫秒,如果millis为0,则会一直执行;
    • LockSupport.parkNanos(long nanos): 除非获得调用许可,否则禁用当前线程进行线程调度指定时间;
    • LockSupport.parkUntil(long deadline):同上,也是禁止线程进行调度指定时间;
  • TERMINATED 终止态

    终止状态。此时线程已执行完毕。

26. 线程池参数 作用

  • 作用 :

    • 提高响应速度,如果线程池有空闲线程的话,可以直接复用这个线程执行任务,而不用去创建。

    • 减少资源占用,每次都创建线程都需要申请资源,而使用线程池可以复用已创建的线程。

    • 可以控制并发数,可以通过设置线程池的最大线程数量来控制最大并发数,如果每次都是创建新线程,来了大量的请求,可能会因为创建的线程过多,造成内存溢出。

    • 更加方便来管理线程资源。

  • 参数:

    • 生产线程的工厂:用来生产线程(好像是句废话…)
    • 核心线程数:线程池长期维持的最小线程数,即使线程处于Idle状态,也不会回收
    • 等待队列:线程池中的线程都处于运行状态,而此时任务数量继续增加,则需要一个容器来容纳这些任务
    • 最大线程数:线程数的上限
    • 拒绝策略:由于超出线程数量和队列容量而对继续增加的任务进行处理的程序
    • 线程生命周期:一个线程能活多长时间

27. ThreadLocal 是什么?

  • ThreadLocal 是一个本地线程副本变量工具类,在每个线程中都创建了一个 ThreadLocalMap 对象,简单说 ThreadLocal 就是一种以空间换时间的做法,每个线程可以访问自己内部 ThreadLocalMap 对象内的 value,通过这种方式,避免资源在多线程间共享,空间换时间

  • 注意:再用ThreadLocal时,使用private final static进行修饰,防止多实例时内存的泄露问题

28. 内存泄漏的原因?内存溢出?

(1)内存泄漏

  • 单例造成的内存泄漏,由于单例的静态特性使得其生命周期和应用的生命周期一样长,如果一个对象已经不再需要使用了,而单例对象还持有该对象的引用,就会使得该对象不能被正常回收,从而导致了内存泄漏。
  • 非静态内部类创建静态实例造成的内存泄漏
  • 线程造成的内存泄漏
  • 资源未关闭造成的内存泄漏
  • 集合容器中的内存泄露
  • 在平时使用时最好及时销毁对象,养成良好的习惯

(2)内存溢出

  • java堆内存溢出
    • 对象创建过多,GC时回收不到 导致堆中对象越来越多,最后溢出
  • 栈溢出
    • 内存设定太小,或者递归次数太多
  • 方法区溢出
    • 加载了大量的数据,或者运行了大量的类(大量的JSP文件,因为JSP第一次运行会编译成Java类)

29.Java内存区域怎么划分的?

  • 运行时数据区域包含以下五个区域:程序计数器,Java虚拟机栈,本地方法栈,堆,方法区(其中前三个区域各线程私有,相互独立,后面两个区域所有线程共享)

  • 线程私用的部分(Java虚拟机栈,本地方法栈,程序计数器)

    • Java虚拟机栈

      执行一个Java方法时,虚拟机都会创建一个栈帧,来存储局部变量表,操作数栈等,方法调用完毕后会对栈帧从虚拟机栈中移除。局部变量表中存储了Java基本类型,对象引用(可以是对象的存储地址,也可以是代表对象的句柄等)和returnAddress类型(存储了一条字节码指令的地址)。

    • 本地方法栈

      本地方法栈与Java虚拟机栈类似,只不过是执行Native方法(C++方法等)。

    • 程序计数器

      计数器存储了当前线程正在执行的字节码指令的地址(如果是当前执行的是Native方法,那么计数器为空),字节码解释器就是通过改变计数器的值来选取下一条需要执行的字节码指令。程序计数器是线程私有的,便于各个线程切换后,可以恢复到正确的执行位置。

  • 线程共享的部分(堆,方法区)

    • Java 堆

      堆存储了几乎所有对象实例和数组,是被所有线程进行共享的区域。在逻辑上是连续的,在物理上可以是不连续的内存空间(在存储一些类似于数组的这种大对象时,基于简单和性能考虑会使用连续的内存空间)。

    • 方法区

      存储了被虚拟机加载的类型信息常量静态变量等数据,在JDK8以后,存储在方法区的元空间中(以前是存储在堆中的永久代中,JDK8以后已经没有永久代了)。

      运行时常量池是方法区的一部分,会存储各种字面量和符号引用。具备动态性,运行时也可以添加新的常量入池(例如调用String的intern()方法时,如果常量池没有相应的字符串,会将它添加到常量池)。

30.Java中对象的创建过程是怎么样的

这是网上看到的一张流程图:
在这里插入图片描述

  • 类加载检查

    首先代码中new关键字在编译后,会生成一条字节码new指令,当虚拟机遇到一条字节码new指令时,会根据类名去方法区运行时常量池找类的符号引用,检查符号引用代表的类是否已加载,解析和初始化过。如果没有就执行相应的类加载过程。

  • 分配内存

    虚拟机从Java堆中分配一块大小确定的内存(因为类加载时,创建一个此类的实例对象的所需的内存大小就确定了),并且初始化为零值。内存分配的方式有指针碰撞空闲列表两种,取决于虚拟机采用的垃圾回收期是否带有空间压缩整理的功能。

  • 指针碰撞

    如果垃圾收集器是Serial,ParNew等带有空间压缩整理的功能时,Java堆是规整的,此时通过移动内存分界点的指针,就可以分配空闲内存。

  • 空闲列表

    如果垃圾收集器是CMS这种基于清除算法的收集器时,Java堆中的空闲内存和已使用内存是相互交错的,虚拟机会维护一个列表,记录哪些可用,哪些不可用,分配时从表中找到一块足够大的空闲内存分配给实例对象,并且更新表。

  • 对象初始化(虚拟机层面)

    虚拟机会对对象进行必要的设置,将对象的一些信息存储在Obeject header 中。

  • 对象初始化(Java程序层面)

    在构造一个类的实例对象时,遵循的原则是先静后动,先父后子,先变量,后代码块,构造器。在Java程序层面会依次进行以下操作:

    • 初始化父类的静态变量(如果是首次使用此类)
    • 初始化子类的静态变量(如果是首次使用此类)
    • 执行父类的静态代码块(如果是首次使用此类)
    • 执行子类的静态代码块(如果是首次使用此类)
    • 初始化父类的实例变量
    • 初始化子类的实例变量
    • 执行父类的普通代码块
    • 执行子类的普通代码块
    • 执行父类的构造器
    • 执行子类的构造器

31. 垃圾回收有哪些特点?

  • 只回收堆内存的对象,不回收其他物理资源(数据库连接等)

  • 无法精准控制内存回收的时机,系统会在合适的时候进行内存回收。

  • 在回收对象之前会调用对象的finalize()方法清理资源,这个方法有可能会让其他变量重新引用对象导致对象复活。

32. 在垃圾回收机制中,对象在内存中的状态有哪几种?

  • 可达状态:有一个及以上的变量引用着对象。

  • 可恢复状态:已经没有变量引用对象了,但是还没有被调用finalize()方法。系统在回收前会调用finalize()方法,如果在执行finalize()方法时,重新让一个变量引用了对象,那么对象会变成可达状态,否则会变成不可达状态。

  • 不可达状态:执行finalize()方法后,对象还是没有被其他变量引用,那么对象就变成了不可达状态。

33. 对象的强引用,软引用,弱引用和虚引用的区别是什么?

  • 强引用

    就是普通的变量对对象的引用,强引用的对象不会被系统回收。

  • 软引用

    当内存空间足够时,软引用的对象不会被系统回收。当内存空间不足时,软引用的对象可能被系统回收。通常用于内存敏感的程序中。

  • 弱引用

    引用级别比软引用低,对于只有软引用的对象,不管内存是否足够,在垃圾回收时, 都可能会被系统回收。

  • 虚引用

    虚引用主要用于跟踪对象被垃圾回收的状态,在垃圾回收时可以收到一个通知。

34.双亲委派模型是什么?

  • 如果一个类收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器执行,如果父加载器还存在其父加载器,则进一步向上委托,依次递归,请求将最终到达顶层的启动类加载器,如果父类加载器可以完成父加载任务,就成功返回,如果父加载器无法完成加载任务,子加载器才会尝试自己去加载,这就是双亲委派模型。

  • 双亲委派模式的优势:

    避免重复加载;考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委派模式传递到启动加载器,而启动加载器在核心Java API中发现同名的类,发现该类已经被加载,就不会重新加载网络传递的Integer类,而直接返回已加载过的Integer.class,这样可以防止核心API库被随意篡改

35.垃圾回收算法有哪些?

  • 垃圾回收算法一般有四种

1581500802565

  • 标记-清除算法(一般用于老年代)

    就是对要回收的对象进行标记,标记完成后统一回收。(CMS垃圾收集器使用这种)

    缺点:

    效率不稳定:执行效率不稳定,有大量对象时,并且有大量对象需要回收时,执行效率会降低。

    内存碎片化:内存碎片化,会产生大量不连续的内存碎片。(内存碎片只能通过使用分区空闲分配链表来分配内存)

  • 标记-复制算法(一般用于新生代)

    就是将内存分为两块,每次只用其中一块,垃圾回收时将存活对象,拷贝到另一块内存。(serial new,parallel new和parallel scanvage垃圾收集器)

    缺点:

    **不适合存活率高的老年代 **存活率较高时需要很多复制操作,效率会降低,所以老年代一般不使用这种算法。

    浪费内存 会浪费一半的内存,解决方案是新生代的内存配比是Eden:From Survivor: To Survivor = 8比1比1每次使用时,Eden用来分配新的对象,From Survivor存放上次垃圾回收后存活的对象,只使用Eden和From Survivor的空间,To Survivor是空的,垃圾回收时将存活对象拷贝到To Survivor,当空间不够时,从老年代进行分配担保。

  • 标记-整理算法(一般用于老年代)

    标记-整理算法跟标记-清除算法适用的场景是一样的,都是用于老年代,也就是存活对象比较多的情况。标记-整理算法的流程就是让存活对象往内存空间一端移动,然后直接清理掉边界以外的内存。(parallel Old和Serial old收集器就是采用该算法进行回收的)

    吞吐量高 移动时内存操作会比较复杂,需要移动存活对象并且更新所有对象的引用,会是一种比较重的操作,但是如果不移动的话,会有内存碎片,内存分配时效率会变低,所以由于内存分配的频率会比垃圾回收的频率高很多,所以从吞吐量方面看,标记-整理法高于标记-清除法。

    延迟高 但是由于需要移动对象,停顿时间会比较长,垃圾回收时延迟会高一些,强调低延迟的CMS收集器一般是大部分时候用标记-清除算法,当内存碎片化程度达到一定程度时,触发Full GC,会使用标记-整理算法清理一次。

  • 分代收集算法

    分代回收器分为新生代和老年代,新生代大概占1/3,老年代大概占2/3;

    新生代包括Eden、From Survivor、To Survivor;

    Eden区和两个survivor区的 的空间比例 为8:1:1 ;

    垃圾回收器的执行流程:

    把 Eden + From Survivor 存活的对象放入 To Survivor 区;
    清空 Eden + From Survivor 分区,From Survivor 和 To Survivor 分区交换;
    每次交换后存活的对象年龄+1,到达15,升级为老年代,大对象会直接进入老年代;
    老年代中当空间到达一定占比,会触发全局回收,老年代一般采取标记-清除算法;

36. 如何确定一个对象是否可以被回收?

有两种算法,一种是引用计数法,可以记录每个对象被引用的数量来确定,当被引用的数量为0时,代表可以回收。 一种是可达性分析法。就是判断对象的引用链是否可达来确定对象是否可以回收。就是把所有对象之间的引用关系看成是一个图,通过从一些GC Roots对象作为起点,根据这些对象的引用关系一直向下搜索,走过的路径就是引用链,当所有的GCRoots对象的引用链都到达不了这个对象,说明这个对象不可达,可以回收。 GCRoots对象一般是当前肯定不会被回收的对象,一般是虚拟机栈中局部变量表中的对象,方法区的类静态变量引用的对象,方法区常量引用的对象,本地方法栈中Native方法引用的对象。

37. 垃圾收集器有哪些?

一般老年代使用的就是标记-整理,或者标记-清除+标记-整理结合(例如CMS)

新生代就是标记-复制算法

垃圾收集器特点算法适用内存区域
Serial单个GC线程进行垃圾回收,简单高效标记-复制新生代
Serial Old单个GC线程进行垃圾回收标记-整理老年代
ParNew是Serial的改进版,就是可以多个GC线程一起进行垃圾回收标记-复制新生代
Parallel Scanvenge收集器(吞吐量优先收集器)高吞吐量,吞吐量=执行用户线程的时间/CPU执行总时间标记-复制新生代
Parallel Old收集器支持多线程收集标记-整理老年代
CMS收集器(并发低停顿收集器)低停顿标记-清除+标记-整理老年代
G1收集器低停顿,高吞吐量标记-复制算法老年代,新生代
  • CMS 收集器(老年代并发低停顿收集器)

    • CMS收集器是第一个支持并发收集的垃圾收集器,在垃圾收集时,用户线程可以和收集线程一起工作,它的执行目标是达到最短回收停顿时间,以获得更好的用户体验。

    • CMS英文是Concurrent Mark Sweep,是基于标记-清除法+标记-整理算法实现的,步骤如下:

    • CMS 垃圾回收器是Concurrent Mark Sweep,是一种同步的标记-清除,CMS分为四个阶段:

      初始标记,标记一下GC Root能直接关联到的对象,会触发“Stop The World”;
      并发标记,通过GC Roots Tracing判断对象是否在使用中;
      重新标记,标记期间产生对象的再次判断,执行时间较短,会触发“Stop The World”;
      并发清除,清除对象,可以和用户线程并发进行;

  • G1收集器

    • 目标是在延迟可控(用户设定的延迟时间)的情况下获得尽可能高的吞吐量。

    • JDK9以前,服务端模式默认的收集器是Parallel Scavenge+Serial Old。JDK9及之后,默认收集器是G1。G1不按照新生代,老年代进行划分,而是将Java堆划分为多个大小相等的独立Region,每一个Region可以根据需要,扮演新生代的Eden空间,Survivor空间,老年代Old空间和用于分配大对象的Humongous区。回收思路是G1持续跟踪各个Region的回收价值(回收可释放的空间和回收所需时间),然后维护一个优先级列表,在用户设定的最大收集停顿时间内,优先回收那些价值大的Region。

    • JDK 8 和9中,Region的大小是通过(最大堆大小+最小堆大小)的平均值/2048,一般是需要在1到32M之间。G1认为2048是比较理想的Region数量

img

  • G1对象分配策略

    说起对象的分配,我们不得不谈谈对象的分配策略。它分为4个阶段:

    • 栈上分配
    • TLAB(Thread Local Allocation Buffer)线程本地分配缓冲区
    • 共享Eden区中分配
    • Humongous区分配(超过Region大小50%的对象)

对象在分配之前会做逃逸分析,如果该对象只会被本线程使用,那么就将该对象在栈上分配。这样对象可以在函数调用后销毁,减轻堆的压力,避免不必要的gc。 如果对象在栈是上分配不成功,就会使用TLAB来分配。TLAB为线程本地分配缓冲区,它的目的为了使对象尽可能快的分配出来。如果对象在一个共享的空间中分配,我们需要采用一些同步机制来管理这些空间内的空闲空间指针。在Eden空间中,每一个线程都有一个固定的分区用于分配对象,即一个TLAB。分配对象时,线程之间不再需要进行任何的同步。

对TLAB空间中无法分配的对象,JVM会尝试在共享Eden空间中进行分配。如果是大对象,则直接在Humongous区分配。

最后,G1提供了两种GC模式,Young GC和Mixed GC,两种都是Stop The World(STW)的。下面我们将分别介绍一下这2种模式。

  • G1 Young GC
    Young GC主要是对Eden区进行GC,它在Eden空间耗尽时会被触发。在这种情况下,Eden空间的数据移动到Survivor空间中,如果Survivor空间不够,Eden空间的部分数据会直接晋升到年老代空间。Survivor区的数据移动到新的Survivor区中,也有部分数据晋升到老年代空间中。最终Eden空间的数据为空,GC停止工作,应用线程继续执行。

img
还有挺多。。。有时间会补全的

文章来源
本文内容多是从其他前辈文章了解,自己只是对春招秋招进行一个总结,摘要链接如下:
(https://blog.csdn.net/guorui_java/article/details/119299329)
(https://github.com/NotFound9/interviewGuide)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值