阅读 108

Java知识点整理

借着面试的机会,复习下Java的知识点,顺便整理下Java面试中一些常见的面试题吧,本文长期更新。。。

Java基础

HashMap

  • 数据结构:HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对(JDK8使用一个Node数组取代了JDK7的Entry数组来存储数据),简单说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的

  • key冲突处理方法: HashMap的key是由hash算法得出的,当发生hash冲突时,比较equals方法,存在即覆盖,否则新增。发生冲突关于entry节点插入链表的方式,JDK7:插入链表的头部,头插法(容易发生闭环)。JDK8:插入链表的尾部,尾插法

  • 长度和扩容方式: HashMap的默认长度为16和规定数组长度为2的幂,是为了降低hash碰撞的几率。HashMap扩容限制的负载因子之所以选择0.75,是为了满足分配内存的合理性、哈希表均匀分布以及减少哈希冲突。

  • ConcurrentHashMap: ConcurrentHashMap只是增加了同步的操作来控制并发,从JDK1.7版本的ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+红黑树

ThreadLocal

  • 作用:在堆内存中,每个线程对应一块工作内存,Threadlocal就是工作内存的一小块内存。ThreadLocal的作用主要是做数据隔离,填充的数据只属于当前线程,变量的数据对别的线程而言是相对隔离的,在多线程环境下,可以防止自己的变量被其它线程篡改。

  • 项目应用:在项目中存在一个线程经常遇到横跨若干方法调用,需要传递的对象,也就是上下文(Context),它是一种状态,经常就是用户身份、任务信息等,就会存在过渡传参的问题。可以使用ThreadLocal去做一下改造,这样只需要在调用前在ThreadLocal中设置参数,其他地方get一下就好了。

  • 数据隔离:ThreadLocal数据隔离是因为每个线程Thread都维护了自己的threadLocals变量,所以在每个线程创建ThreadLocal的时候,实际上数据是存在自己线程Thread的threadLocals变量里面的,别人没办法拿到,从而实现了隔离。

  • 数据结构:一个Thread对应的threadLocals维护了一个ThreadLocalMap来存放ThreadLocal里的信息,ThreadLocal作为key,参数作为value。

  • 线程共享ThreadLocal:使用InheritableThreadLocal可以实现多个线程访问ThreadLocal的值,我们在主线程中创建一个InheritableThreadLocal的实例,然后在子线程中得到这个InheritableThreadLocal实例设置的值。

  • 内存泄露问题:ThreadLocal在保存的时候会把自己当做Key存在ThreadLocalMap中,但是现在key被设计成WeakReference弱引用了。这就导致了ThreadLocal在没有外部强引用时,发生GC时会被回收,如果创建ThreadLocal的线程一直持续运行,那么这个Entry对象中的value就有可能一直得不到回收,发生内存泄露。可以在代码的最后使用remove,所以我们只要记得在使用的最后用remove把值清空就好了。

volatile

  • 简介:volatile是Java提供的一种轻量级的同步机制。Java 语言包含两种内在的同步机制:同步块(或方法)和 volatile 变量,相比于synchronized(synchronized通常称为重量级锁),volatile更轻量级,因为它不会引起线程上下文的切换和调度。但是volatile 变量的同步性较差(有时它更简单并且开销更低),而且其使用也更容易出错。

  • volatile变量的特性

  1. 保证可见性,不保证原子性
  • 当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去。
  • 这个写操作会导致其他线程中的volatile变量缓存无效。
  1. 禁止指令重排
  • 重排序操作不会对存在数据依赖关系的操作进行重排序。

  • 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变.

  • volatile原理:volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  1. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  2. 它会强制将对缓存的修改操作立即写入主存;
  3. 如果是写操作,它会导致其他CPU中对应的缓存行无效。
  • volatile为什么不能保证原子性:内存屏障是线程安全的,但是内存屏障之前的指令并不是.在某一时刻线程1将i的值load取出来,放置到cpu缓存中,然后再将此值放置到寄存器A中,然后A中的值自增1(寄存器A中保存的是中间值,没有直接修改i,因此其他线程并不会获取到这个自增1的值)。如果在此时线程2也执行同样的操作,获取值i==10,自增1变为11,然后马上刷入主内存。此时由于线程2修改了i的值,此时的线程1中的i==10的值缓存失效,重新从主内存中读取,变为11。接下来线程1恢复。将自增过后的A寄存器值11赋值给cpu缓存i。这样就出现了线程安全问题。

Synchronized

synchronized特点:保证内存可见性、操作原子性。

synchronized影响性能的原因

1、加锁解锁操作需要额外操作;

2、互斥同步对性能最大的影响是阻塞的实现,因为阻塞涉及到的挂起线程和恢复线程的操作都需要转入内核态中完成(用户态与内核态的切换的性能代价是比较大的)

synchronized锁:对象头中的Mark Word根据锁标志位的不同而被复用

偏向锁:在只有一个线程执行同步块时提高性能。Mark Word存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单比较ThreadID。特点:只有等到线程竞争出现才释放偏向锁,持有偏向锁的线程不会主动释放偏向锁。之后的线程竞争偏向锁,会先检查持有偏向锁的线程是否存活,如果不存活,则对象变为无锁状态,重新偏向;如果仍存活,则偏向锁升级为轻量级锁,此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁

轻量级锁:在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,尝试拷贝锁对象目前的Mark Word到栈帧的Lock Record,若拷贝成功:虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向对象的Mark Word。若拷贝失败:若当前只有一个等待线程,则可通过自旋稍微等待一下,可能持有轻量级锁的线程很快就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁

重量级锁:指向互斥量(mutex),底层通过操作系统的mutex lock实现。等待锁的线程会被阻塞,由于Linux下Java线程与操作系统内核态线程一一映射,所以涉及到用户态和内核态的切换、操作系统内核态中的线程的阻塞和恢复。

偏向锁、轻量级锁、重量级锁之间转换

  • 一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。

  • 一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了。检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程。如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁;如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。

  • 轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。

Synchronized和Lock的区别

  1. 首先synchronized是java内置关键字在jvm层面,Lock是个java类。
  2. synchronized无法判断是否获取锁的状态,Lock可以判断是否获取到锁,并且可以主动尝试去获取锁。
  3. synchronized会自动释放锁(a 线程执行完同步代码会释放锁 ;b 线程执行过程中发生异常会释放锁),Lock需在finally中手工释放锁(unlock()方法释放锁),否则容易造成线程死锁。
  4. 用synchronized关键字的两个线程1和线程2,如果当前线程1获得锁,线程2线程等待。如果线程1阻塞,线程2则会一直等待下去,而Lock锁就不一定会等待下去,如果尝试获取不到锁,线程可以不用一直等待就结束了。
  5. synchronized的锁可重入、不可中断、非公平,而Lock锁可重入、可中断、可公平(两者皆可)
  6. Lock锁适合大量同步的代码的同步问题,synchronized锁适合代码少量的同步问题。

Java线程池

为什么要使用线程池

  1. 降低资源消耗。通过重复利用已经创建的线程降低线程创建和销毁造成的消耗。例如,工作线程 Woker 会无线循环获取阻塞队列中的任务来执行。
  2. 提高响应速度。 当任务到达时,任务可以不需要等到线程创建就能立即执行。
  3. 提高线程的可管理性。 线程是稀缺资源,Java 的线程池可以对线程资源进行统一分配、调优和监控。

核心线程池 ThreadPoolExecutor 的参数

  1. corePoolSize:指定了线程池中的线程数量
  2. maximumPoolSize:指定了线程池中的最大线程 数量
  3. keepAliveTime:线程池维护线程所允许的空闲时间
  4. unit: keepAliveTime 的单位。
  5. workQueue:任务队列,被提交但尚未被执行的任务。
  6. threadFactory:线程工厂,用于创建线程,一般用默认的即可。
  7. handler:拒绝策略。当任务太多来不及处理,如何拒绝任务。

ThreadPoolExecutor 的工作流程

  • 一个新的任务到线程池时,线程池的处理流程如下:

    1. 线程池判断核心线程池里的线程是否都在执行任务。 如果不是,创建一个新的工作线程来执行任务。如果核心线程池里的线程都在执行任务,则进入下个流程。
    2. 线程池判断阻塞队列是否已满,如果阻塞队列没有满,则将新提交的任务存储在阻塞队列中。如果阻塞队列已满,则进入下个流程。
    3. 线程池判断线程池里的线程是否都处于工作状态。 如果没有,则创建一个新的工作线程来执行任务。如果已满,则交给饱和策略来处理这个任务。
  • 线程池的核心实现类是 ThreadPoolExecutor 类,用来执行提交的任务。因此,任务提交到线程池时,具体的处理流程是由 ThreadPoolExecutor 类的 execute()方法去完成的。

    1. 如果当前运行的线程少于 corePoolSize,则创建新的工作线程来执行任务(执行这一步骤需要获取全局锁)。
    2. 如果当前运行的线程大于或等于 corePoolSize,而且 BlockingQueue 未满,则将任务加入到 BlockingQueue 中。
    3. 如果 BlockingQueue 已满,而且当前运行的线程小于 maximumPoolSize,则创建新的工作线程来执行任务(执行这一步骤需要获取全局锁)。
    4. 如果当前运行的线程大于或等于 maximumPoolSize,任务将被拒绝,并调用 RejectExecutionHandler.rejectExecution()方法。即调用饱和策略对任务进行处理。
  • 工作线程(Worker): 线程池在创建线程时,会将线程封装成工作线程 Woker。Woker 在执行完任务后,不是立即销毁而是循环获取阻塞队列里的任务来执行。

文章分类
后端
文章标签