Java高频面试八股文-上半
Java中有几种方式来创建线程执行任务
可以说有一种也可以说有四种,一种是因为Java中创建线程执行的方式本质上都是使用Runnable接口实现;
而四种分别是:
- 继承Thread类来重写run方法实现
- 实现Runnable接口重写run方法
- 实现Callable接口使用futureTast实现call方法来创建线程,这种可以返回线程执行结果
- 通过线程池创建线程
为什么不建议使用Executors来创建线程池
Executors创建线程池的方式是调用LinkedBlockingQueue,它是一个无界阻塞队列,在线程执行任务时,可能会导致任务过多而不停的添加至队列中,而导致系统内存耗尽,最终导致OOM。
而SingleThreadExector同样是调用LinkedBlockingQueue,所以建议使用ThreadPoolExector来定义线程池
线程池有几种状态?每种状态分别代表什么意思?
- RUNNING 正常运行
- SHUTDOWN shutdown() 不会接受新任务,但继续执行
- STOP shutdownnow() 停止运行
- TIDYING terminated() 线程池没有线程运行,进入空方法terminated()
- TERMINATED terminated()执行完
Sychronized和ReentrantLock有那些区别?
Sychronized
- Java中的一个关键字
- 自动加锁与释放锁
- JVM层面的锁
- 非公平锁
- 锁的是对象,锁信息保存在对象头中
- 底层有锁升级过程
ReentrantLock
- JDK提供的一个类
- 需要手动加锁与释放锁
- API层面的锁
- 公平锁或非公平锁
- int类型的state标识来标识锁的状态
- 没有锁升级过程
CAS
CAS是乐观锁,线程执行的时候不会加锁,假设没有冲突去完成某项操作,如果因为冲突失败了就重试,最后直到成功为止。
compare and swap多线程访问,在没有加锁的情况下,保证线程一致性的去改变某个值,比如有个变量初始值0,第一个线程读取过来,想加1,在把1往回写的时候需要先去读最新的值,看看还是不是0,如果是,则把值改成1,如果原来的值已经被别的线程动了,改成2了,那么此时cas失败,值还是2。此时第一个线程虽然cas失败了,但是并不会被挂起,而是自旋,他会把最新的2读取,然后+1,再把3写回去的时候依然去判断原来的值是否被别的线程改变,如果改变了继续重复上述步骤。
ThreadLocal有哪些应用场景?它底层是如何实现的?
ThreadLocal是JAVA内部提供的一个线程本地储存机制,可以将数据缓存到线程内部,应用于多个类传递数据时的场景,底层是通过ThreadLocalMap实现的,每一个Thread对象都存在一个ThreadLocalMap,其对象名为Map的Key,缓存的数据为Value,但要注意的是ThreadLocal内部使用的是强引用指向的Map,导致使用完以后不会自动回收内存,需要在应用场景结束后手动remove,手动清除Entry对象
ReentrantLock分为公平锁和非公平锁,那底层分别是如何实现的?
首先不管是公平锁和非公平锁,它们的底层实现都会使用AQS来进行排队,它们的区别在于线程在使用lock()方法加锁时:
- 如果是公平锁,会先检查AQS队列中是否存在线程在排队,如果有线程在排队,则当前线程也进行排队
- 如果是非公平锁,则不会去检查是否有线程在排队,而是直接竞争锁。
另外,不管是公平锁还是非公平锁,一旦没竞争到锁,都会进行排队,当锁释放时,都是唤醒排在最前面的线程,所以非公平锁只是体现在了线程加锁阶段,而没有体现在线程被唤醒阶段,ReentrantLock是可重入锁,不管是公平锁还是非公平锁都是可重入的。
Sychronized的锁升级过程是怎样的?
- 偏向锁:在锁对象的对象头中记录当前获取到的锁的线程ID,线程下次来获取直接获取;
- 轻量级锁:由偏向锁升级而来,在有一个线程获取锁以后,另一个线程来竞争锁,会导致锁进入自旋状态;
- 重量级锁:在多次自旋无果以后依旧没有获取到锁,会从轻量级升级为重量级锁,导致线程阻塞;
- 自旋锁:自旋就是在线程获取锁的时候,不会去阻塞线程,而是通过CAS不停获取标记,省去了阻塞和唤醒两个消耗系统资源的步骤,因为CAS是不停的循环获取标记,直到获取成功。
Tomcat中为什么要使用自定义类加载器?
因为Tomcat中可以部署多个应用,而多个应用中可能会出现同名的类,尽管功能不同,但Tomcat启动以后是作为一个进程存在的,所以进程中不允许出现同类名,所以要为每一个应用都生成一个类加载实例WebAppClassLoarder,不同的类加载器可以隔离每个应用的同名类,同时自定义类加载器可以实现热加载。
什么是面向对象
面向对象是一种开发方式,相比与面向过程的连续,它更多的注重于对象本身以及对象所要进行的操作,将相关的数据与方法组合成一个整体来看,虽然不如面向过程简单便捷,但更易于维护与管理。
面向对象的三个特性
- 封装:对象中的数据与代码可以被封装起来,私有数据与代码不给外部访问,公有的则是内部给外部留下使用方法,对象的内部细节外部无需了解,外部的调用也无需关心与修改内部实现;
- 继承:子类可以继承父类的方法,好处是无需对基类的通用方法重复造轮子,同时可以在子类自定义及扩展自己需要的方法,重写是例子;
- 多态:基于对象所属类的不同,外部对同一个方法的调用,实际执行的逻辑不同,多种逻辑可以用一个方法调用实现,构造方法重载是例子。
JDK、JRE、JVM的区别
- JVM 虚拟机
- JRE 运行环境 lib类库与jvm
- JDK 开发环境 包含JRE与java工具
- Servlet 经典web开发
- Spring web开发脚手架(框架) 配置地狱
- Spring boot 大部分用默认配置代替的优化版Spring
- Swagger 多人开发中的日志 与前端接口交流
- Redis 缓存数据库
- Mybatis 代替JDBC的数据库整合
- Druid alibaba的数据库监管
- Shiro || Security 用户名密码验证安全框架
- Thymeleaf 前端模板
- zookeeper 分布式的注册中心 需要Dubbo去连接
- Dubbo 分布式开发的工具 可以让B去注册中心使用A注册的服务 负载均衡
final的作用
- 修饰类时,类不可被继承
- 修饰方法时,不可被子类覆盖,但可以重载
- 修饰变量时,赋值以后不可改变
- 内部类:写在一个类中的独立类,可以调用外部类数据及代码,外部类需要内部类对象来访问成员;
- 匿名内部类:只使用一次的匿名内部类,继承父类或实现父类接口,将使用接口的定义子类,重写接口方法,new子类对象,调用重写后方法整合成一个步骤:匿名内部类;
- 局部内部类:局部内部类与局部变量一样,不能使用访问控制修饰符(public、private 和 protected)和 static 修饰符修饰。局部内部类只在当前方法中有效。局部内部类中只可以访问当前方法中 final 类型的参数与变量。
首先需要知道的一点是:内部类和外部类是处于同一个级别的,内部类不会因为定义在方法中就会随着方法的执行完毕就被销毁。
这里就会产生问题:当外部类的方法结束时,局部变量就会被销毁了,但是内部类对象可能还存在(只有没有人再引用它时,才会死亡)。这里就出现了一个矛盾:内部类对象访问了一个不存在的变量。为了解决这个问题,就将局部变量复制了一份作为内部类的成员变量,这样当局部变量死亡后,内部类仍可以访问它,实际访问的是局部变量的”copy”。这样就好像延长了局部变量的生命周期
将局部变量复制为内部类的成员变量时,必须保证这两个变量是一样的,也就是如果我们在内部类中修改了成员变量,方法中的局部变量也得跟着改变,怎么解决问题呢?
就将局部变量设置为final,对它初始化后,我就不让你再去修改这个变量,就保证了内部类的成员变量和方法的局部变量的一致性。这实际上也是一种妥协。使得局部变量与内部类内建立的拷贝保持一致。
总结:本质上是因为内部类在运行结束外部类销毁时,内部类不会因为外部类的销毁而不存在,而内部类依旧存在的情况下仍然在调用之前使用的局部变量,但是变量已经随着方法的销毁而不存在了,所以内部类使用的本质上是变量的copy,所以需要保证两个变量的值相同,使用final关键字使变量的值不会更改。
List和Set的区别
- List:有序,按对象进入的顺序保存对象,可重复,允许多个Null元素对象,可以使用lterator取出所有元素,在逐—遍历,还可以使用get(int index)获取指定下表的元素;
- Set:无序,不可重复,最多允许有一个Null元素对象,取元素时只能用lterator接口取得所有元素,在逐一遍历各个元素
HashMap的扩容机制
HashMap的扩展原理是HashMap用一个新的数组替换原来的数组。重新计算原数组的所有数据并插入一个新数组,然后指向新数组。如果阵列在容量扩展前已达到最大值,阈值将直接设置为最大整数返回。
hashMap扩容就是重新计算容量,向hashMap不停的添加元素,当hashMap无法装载新的元素,对象将需要扩大数组容量,以便装入更多的元素。
- 1.7版本
- 先生成新数组
- 遍历老数组中的每个位置上的链表上的每个元素
- 取每个元素的key,并基于新数组长度,计算出每个元素在新数组中的下标
- 将元素添加到新数组中去
- 所有元素转移完了之后,将新数组赋值给HashMap对象的table属性
- 1.8版本(新增了红黑树)
- 先生成新数组
- 遍历老数组中的每个位置上的链表或红黑树
- 如果是链表,则直接将链表中的每个元素重新计算下标,并添加到新数组中去
- 如果是红黑树,则先遍历红黑树,先计算出红黑树中每个元素对应在新数组中的下标位置
a. 统计每个下标位置的元素个数
b. 如果该位置下的元素个数超过了8,则生成一个新的红黑树,并将根节点的添加到新数组的对应位置
c.如果该位置下的元素个数没有超过8,那么则生成一个链表,并将链表的头节点添加到新数组的对应位置
5.所有元素转移完了之后,将新数组赋值给HashMap对象的table属性
HashMap和HashTable的区别? 底层实现是什么?
- 区别:
(1) HashMap方法没有synchronized修饰,线程非安全,HashTable线程安全;
(2) HashMap允许key和value为null,而HashTable不允许 - 底层实现:数组+链表实现
jdk8开始链表高度到8、数组长度超过64,链表转变为红黑树,元素以内部类Node节点存在;
- 计算key的hash值,二次hash然后对数组长度取模,对应到数组下标,
- 如果没有产生hash冲突(下标位置没有元素),则直接创建Node存入数组,
- 如果产生hash冲突,先进行equal比较,相同则取代该元素,不同,则判断链表高度插入链表,链表高度达到8,并且数组长度到64则转变为红黑树,长度低于6则将红黑树转回链表
- key为null,存在下标0的位置数组扩容
ArrayList和LinkedList区别
- ArrayList:基于动态数组,连续内存存储,适合下标访问(随机访问),扩容机制:因为数组长度固定,超出长度存数据时需要新建数组,然后将老数组的数据拷贝到新数组,如果不是尾部插入数据还会涉及到元素的移动(往后复制一份,插入新元素),使用尾插法并指定初始容量可以极大提升性能、甚至超过linkedList(需要创建大量的node对象);
- LinkedList:基于链表,可以存储在分散的内存中,适合做数据插入及删除操作,不适合查询:需要逐一遍历遍历LinkedList必须使用iterator不能使用for循环,因为每次for循环体内通过geti取得某一元素时都需要对list重新进行遍历,性能消耗极大。另外不要试图使用indexOf等返回元素索引,并利用其进行遍历,使用indexlof对list进行了遍历,当结果为空时会遍历整个列表。
ConcurrentHashMap的扩容机制
1.7版本: ConcurrentHashMap是由一个个Segment对象实现,而扩容则是对在Segment对象中的数组实现扩容操作;
- 1.7版本的ConcurrentHashMap是基于Segment分段实现的
- 每个Segment相对于一个小型的HashMap
- 每个segment内部会进行扩容,和HashMap的扩容逻辑类似
- 先生成新的数组,然后转移元素到新数组中
- 扩容的判断也是每个segment内部单独判断的,判断是否超过阈值
1.8版本: ConcurrentHashMap不再基于Segment实现,而是线程生成数组扩容,且支持多个线程同时扩容,扩容前会先判断是否正在进行扩容操作,如果没有,先将key-value放入ConcurrentHashMap中再判断是否超过阀值。 - 1.8版本的ConcurrentHashMap不再基于Segment实现
- 当某个线程进行put时,如果发现ConcurrentHashMap正在进行扩容那么该线程一起进行扩容
- 如果某个线程put时,发现没有正在进行扩容,则将key-value添加到ConcurrentHashMap中,然后判断是否超过阈值,超过了则进行扩容
- ConcurrentHashMap是支持多个线程同时扩容的
- 扩容之前也先生成一个新的数组
- 在转移元素时,先将原数组分组,将每组分给不同的线程来进行元素的转移,每个线程负责一组或多组的元素转移工作
ConcurrentHashMap原理,jdk7和jdk8版本的区别
jdk7:
数据结构:ReentrantLock+Segment+HashEntry,一个Segment中包含一个HashEntry数组,每个HashEntry又是一个链表结构;
元素查询:二次hash,第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部;
锁: Segment分段锁Segment继承了ReentrantLock,锁定操作的Segment,其他的Segment不受影响,并发度为segment个数,可以通过构造函数指定,数组扩容不会影响其他的segment;
get方法无需加锁,volatile保证
jdk8:
数据结构: synchronized+CAS+Node+红黑树,Node的val和next都用volatile修饰,保证可见性查找,替换,赋值操作都使用CAS;
锁:锁链表的head节点,不影响其他元素的读写,锁粒度更细,效率更高,扩容时,阻塞所有的读写操作、并发扩容;
读操作无锁:
Node的val和next使用volatile修饰,读写线程对该变量互相可见数组用volatile修饰,保证扩容时被读线程感知
volatile:
volatile是Java中的关键字,用来修饰会被不同线程访问和修改的变星。JMM(Java内存模型)是围绕并发过程中如何处理可见性、原子性和有序性这3个特征建立起来的,而volatile可以保证其中的两个特性。
- 保证可见性
- 不保证原子性
- 禁止指令重排(保证有序性)
CopyOnWriteArrayList的底层原理是怎样的
因为ArrayList本身线程不安全的缘故,并发过程中会导致数据丢失,使用CopyOnWriteArrayList;
- 首先CopyOnWiteArrayList内部也是用过数组来实现的,在向CopyOnWriteAraylist添加元素时,会复制一个新的数组,写操作在新数组上进行,读操作在原数组上进行
- 并且,写操作会加锁,防止出现并发写入丢失数据的问题
- 写操作结束之后会把原数组指向新数组
- CopyOnWriteArrayList允许在写操作时来读取数据,大大提高了读的性能,因此适合读多写少的应用场景,但是CopyOnWriteArraylit会比较占内存,同时可能读到的数据不是实时最新的数据,所以不适合实时性要求很高的场景
String、 StringBuffer、StringBuilder的区别
- String是不可变的,如果尝试去修改,会新生成一个字符串对象,StringBuffer和StringBuilder是可变的
- StringBuffer是线程安全的,StringBuilder是线程不安全的,所以在单线程环境下stringBuilder效率会更高
总结:区别就是String是字符串,StringBuffer和StringBuilder是操作字符串,但是StringBuffer线程安全,有锁Synchronized。
String、StringBuffer、StringBuilder区别及使用场景
- string是final修饰的,不可变,每次操作都会产生新的String对象
- stringBuffer和stringBuilder都是在原对象上操作
- stringBuffer是线程安全的,StringBuilder线程不安全的
- stringBuffer方法都是synchronized修饰的
- 性能: StringBuilder > stringBuffer > string
- 场景:经常需要改变字符串内容时使用后面两个
- 优先使用StringBuilder,多线程使用共享变量时使用StringBuffer
阿里二面:Jdk1.7到Jdk1.8 HashMap发生了什么变化(底层)?
- 1.7中底层是数组+链表,1.8中底层是数组+链表+红黑树,加红黑树的目的是提高HashMap插入和查询整体效率;数组超过64,链表超过8就会启用红黑树
- 1.7中链表插入使用的是头插法,1.8中链表插入使用的是尾插法,因为1.8t中插入key和Vale时需要判断链表示素个数,所以需要遍历锥表统计铤表元索个数,所以正好就直接使用尾插法;
- 1.7中哈希算法比较复杂,存在各种右移与异或运算,1.8中进行了简化,因为负载的哈希算法的目的就是提高散列性,来提供HasthMap的整体效率,而1.8中新增了红黑树,所以可以适当的简化哈希算法,节省CPu资源。
阿里二面:Jdk1.7到Jdk1.8 java虚拟机发生了什么变化?
1.7中存在永久代,1.8中没有永久代,替换它的是元空间,元空间所占的内存不是在虚拟机内部,而是本地内存空间,这么做的原因是,不管是永久代还是元空间,他们都是方法区的具体实现,之所以元空间所占的内存改成本地内存,官方的说法是为了和Rocit统一,不过额外还有一些原因,比如方法区所存储的类信息通常是比较难确定的,所以对于方法区的大小是比较难指定的,太小了容易出现方法区溢出,太大了又会占用了太多虚拟机的内存空间,而转移到本地内存后则不会影响虚拟机所占用的内存
阿里一面:说一下HashMap的Put方法
先说HashMap的Put方法的大体流程;
1.根据Key通过哈希算法与与运算得出数组下标
⒉如果数组下标位置元素为空,则将key和value封装为Entry对象(JDK1.7中是Entry对象,JDK1.8中是Node对象)并放入该位晋
3.如果数组下标位置元素不为空,则要分情况讨论
a.如果是JDK1.7,则先判断是否需要扩容,如果要扩容就进行扩容,如果不用扩容就生成Entry对象,并使用头插法添加到当前位置的链表中b.如果是JDK1.8,则会先判断当前位置上的Node的类型,看是红黑树Node,还是链表Node
b.如果是红黑树Node,则将key和value封装为一个红黑树节点并添加到红黑树中去,在这个过程中会判断红黑树中是否存在当前key,如果存在则更新value
c.如果此位置上的Node对象是链表节点,则将key和value封装为一个链表Node并通过尾插法插入到镞表的最后位置去,因为是尾插法,所以需要遍历镞表,在遍历链表的过程中会判断是否存在当前key,如果存在则更新value,当遍历完)表后,将新链表Node插入到链表中,插入到链表后,会看当前链表的节点个数,如果超过了8,那么则会将该链表转成红黑树
d.将key和value封装位Node插入到链表或红黑树中后,再判断是否需要进行扩容,如果需要就扩容,如果不需要就介绍PUT方法
总结:本质上就是key根据哈希算法和与算法得出数组下标,无元素存放就封装为1.7:entry对象或1.8:node对象直接放进去,有元素存放1.7直接头插法,1.8则是需要判断链表与红黑树:
- 红黑树就封装为红黑树节点放进去,同时判断是否存在当前key来更新value;
- 链表封装成链表node尾插法放入,尾插法需要遍历,同时判断是否存在当前key来更新value,放完以后判断链表数量是否超过8,超过则转为红黑树
- 最后再判断一边是否扩容
重载和重写的区别
重载:发生在同一个类中,方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以不同,发生在编译时。
重写:发生在父子类中,方法名、参数列表必须相同,返回值范围小于等于父类,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类;如果父类方法访问修饰符为private则子类就不能重写该方法。
总结:
- 重载的参数一定要不同,可以是个数,次序,类型;
- 重写与父类的方法名参数都要一样,返回值与异常小于等于,访问修饰符大于等于,private禁止重写;
ReentrantLock中tryLock()和lock()方法的区别
- tryLock()表示尝试加锁,可能加到,也可能加不到,该方法不会阻塞线程,如果加到锁则返回true,没有加到则返回false,通常用于自旋锁;
- lock()表示阻塞加锁。线程会阻塞直到加到锁,方法也没有返回值;
sleep()、wait()、join()、yield()的区别
- 锁池
所有需要竞争同步锁的线程都会放在锁池当中,比如当前对象的锁已经被其中一个线程得到,则其他线程需要在这个锁池进行等待,当前面的线程释放同步锁后锁池中的线程去竞争同步锁,当某个线程得到后会进入就绪队列进行等待cpu资源分配。 - 等待池
当我们调用wait ()方法后,线程会放到等待池当中,等待池的线程是不会去竞争同步锁。只有调用了notify ()或notifyAll)后等待池的线程才会开始去竞争锁,notify ()是随机从等待池选出一个线程放到锁池,而notifyAllp)是将等待池的所有线程放到锁池当中
sleep和wait的区别
- sleep是Thread类的静态本地方法,wait则是Object类的本地方法。
- sleep方法不会释放lock,但是wait会释放,而且会加入到等待队列中。
- sleep方法不依赖于同步器synchronized,但是wait需要依赖synchronized关键字。
- sleep不需要被唤醒(休眠之后推出阻塞),但是wait需要(不指定时间需要被别人中断)。
- sleep一般用于当前线程休眠,或者轮循暂停操作,wait 则多用于多线程之间的通信。
- sleep会让出CPU执行时间且强制上下文切换,而wait则不一定,wait后可能还是有机会重新竞争到锁继续执行的。
yield () 执行后线程直接进入就绪状态,马上释放了cpu的执行权,但是依然保留了cpu的执行资格,所以有可能cpu下次进行线程调度还会让这个线程获取到执行权继续执行。
join () 执行后线程进入阻塞状态,例如在线程B中调用线程A的join (),那线程B会进入到阻塞队列,直到线程A结束或中断线程
总结:
- sleep是直接强制休眠线程直到指定的时间结束
- wait是让线程进入等待池直到手动notify唤醒或者设置时间
- yield是进入就绪状态,跟sleep差不多强制释放cpu但是保留了接下来可能获取cpu执行的权利
- join是强制一个线程优先插队到另一个线程中,直到插队线程结束或中断
ThreadLocal的底层原理
- ThreadLocal是Java中所提供的线程本地存储机制,可以利用该机制将数据绥存在某个线程内部,该线程可以在任意时刻、任意方法中获取缓存的数据
- ThreadLocal底层是通过ThreadLocalMap来实现的,每个Thread对象(注意不是ThreadLocal对象)中都存在一个ThreadLlocalMap,Map的key为ThreadLocal对象,Map的value为需要缓存的值
- 如果在线程池中使用ThreadLocal会造成内存泄漏,因为当ThreadLocal对象使用完之后,应该要把设置的key,value,也就是Entry对象进行回收,但线程池中的线程不会回收,而线程对象是通过强引用指向ThreadLocalMap,ThreadLocalMap也是通过强引用指向Entry对象,线程不被回收,Entry对象也就不会被回收,从而出现内存泄漏,解决办法是,在使用了ThreadLocal对象之后,手动调用Threadlocll的remove方法,手动清楚Entry对象
- ThreadLocal经典的应用场景就是连接管理(一个线程持有一个连接,该连接对象可以在不同的方法之间进行传递,线程之间不共享同一个连接)
ThreadLocal的原理和使用场景
每一个Thread对象均含有一个ThreadLoca1Map类型的成员变量threadLocals,它存储本线程中所有ThreadLocal对象及其对应的值
ThreadLoca1Map由一个个Entry对象构成
Entry继承自weakReference
当执行set方法时,ThreadLocal首先会获取当前线程对象,然后获取当前线程的ThreadLocalMap对象。再以当前ThreadLocal对象为key,将值存储进ThreadLocalMap对象中。
get方法执行过程类似。ThreadLocal首先会获取当前线程对象,然后获取当前线程的ThreadLocalMap对象。再以当前ThreadLocal对象为key,获取对应的value。
由于每一条线程均含有各自私有的ThreadLocalMap容器,这些容器相互独立互不影响,因此不会存在线程安全性问题,从而也无需使用同步机制来保证多条线程访问容器的互斥性。
使用场景:
- 在进行对象跨层传递的时候,使用ThreadLocal可以避免多次传递,打破层次间的约束。
- 线程间数据隔离
- 进行事务操作,用于存储线程事务信息。
ThreadLocal内存泄露原因,如何避免
内存泄露为程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光,
不再会被使用的对象或者变量占用的内存不能被回收,就是内存泄露。
强引用:使用最普遍的引用(new),一个对象具有强引用,不会被垃圾回收器回收。当内存空问不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不回收这种对象。
如果想取消强引用和某个对象之间的关联,可以显式地将引用赋值为null,这样可以使VM在合适的时间就会回收该对象。
弱引用:JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在java中,用java.lang.ref.WeakReference类来表示。可以在缓存中使用弱引用。
ThreadLocal的实现原理,每一个Thread维护一个ThreadLocalMap,key为使用弱引用的ThreadLocal实例,value为线程变量的副本
threadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal不存在外部强引用时,Key(ThreadLocal)势必会被GC回收,这样就会导致ThreadLocalMap中key为null,而value还存在着强引用,只有thread线程退出以后,value的强引用链条才会断掉,但如果当前线程再迟迟不结束的话,这些key为nul的Entry的value就会一直存在一条强引用链;
key使用强引用
当threadLocalMap的key为强引用回收ThreadLocal时,因为ThreadLocalMap还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收,导致Entry内存泄漏。
key使用弱引用
当ThreadLocalMap的key为弱引用回收ThreadLocal时,由于ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。当key为null,在下一次ThreadLocalMap调用set(),get(), remove()方法的时候会被清除value值。
因此,,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。
ThreadLocal正确的使用方法
- 每次使用完ThreadLocal都调用它的remove()方法清除数据
- 将ThreadLocal变量定义成private static,这样就一直存在ThreadLocal的强引用,也就能保证任何时候都能通过ThreadLocal的弱引用访问到Entry的value值,进而清除掉。
阿里一面:如何查看线程死锁
- 可以通过jstack命令来进行查看,jstack命令中会显示发生了死锁的线程
- 或者两个线程去操作数据库时,数据库发生了死锁,这是可以查询数据库的死锁情况
阿里一面:线程之间如何进行通讯的
- 线程之间可以通过共享内存或基于网络来进行通信
- 如果是通过共享内存来进行通信,则需要考虑并发问题,什么时候阻塞,什么时候唤醒
- 像Java中的wait0、notify0就是阻塞和唤醒
- 通过网络就比较简单了,通过网络连接将通信数据发送给对方,当然也要考虑到并发问题,处理方式就是加锁等方式
并发、并行、串行的区别
- 串行在时间上不可能发生重叠,前一个任务没搞定,下一个任务就只能等着 串联
- 并行在时间上是重叠的,两个任务在同一时刻互不干扰的同时执行。 并联
- 并发允许两个任务彼此干扰。统一时间点、只有一个任务运行,交替执行 并联加开关
京东二面:并发编程三要素?
- 原子性:不可分割的操作,多个步骤要保证同时成功或同时失败
- 有序性:程序执行的顺序和代码的顺序保持—致
- 可用性:—个线程对共享变量的修改,另个线程能立马看到
Java死锁如何避免?
造成死锁的几个原因:
- 一个资源每次只能被一个线程使用
- —个线程在阻塞等待某个资源时,不释放已占有资源
- —个线程已经获得的资源,在未使用完之前,不能被强行剥夺4.若干线程形成头尾相接的循环等待资源关系
这是造成死锁必须要达到的4个条件,如果要避兔死锁,只需要不满足其中某一个条件即可。而其中前3个条件是作为救要符合的条件,所以要避兔死赖僦需要打破第4个条件,不出现循环等待锁的关系。
在开发过程中:
- 要注意加锁顺序,保证每个线程按同样的顺序进行加锁
- 要注意加锁时限,可以针对所设置一个超时时间
- 要注意死锁检查,这是一种预防机制,确保在第一时间发现死锁并进行解决
volatile关键字,他是如何保证可见性,有序性
- 对于加了iuolatle关键字的成员变量,在对这个变量进行修改时,会直接将CPU高级缓存中的数据写回到主内存,对这个变量的读取也会直接从主内存中读取,从而保证了可见性
- 在对volatile修饰的成员变量进行读写时,会插入内存屏障,而内存屏障可以达到禁止重排序的效果,从而可以保证有序性
线程池的底层工作原理
线程池内部是通过队列+线程实现的,当我们利用线程池执行任务时:
- 如果此时线程池中的线程数量小于corePoolSize,即使线程池中的线程都处于空闲状态,也要创建新的线程来处理被添加的任务。
- 如果此时线程池中的线程数量等于corePoolSize,但是缓冲队列workQueue未满,那么任务被放入缓冲队列。
- 如果此时线程池中的线程数量大于等于corePoolSize,缓冲队列workQueue满,并且线程池中的数量小于maximumPoolSize,建新的线程来处理被添加的任务。
- 如果此时线程池中的线程数量大于corePoolSize,缓冲队列workQueuc满,并且线程池中的数量等于maximumPoolSize,那么通过handler新指定的策略来处理此任务。
- 当线程池中的线程数量大于corePoolSize时,如果某线程空闲时间超过keepAliveTime,线程将被终止。这样,线程池可以动态的调整池中的线程数
JAVA类加载
JAVA类加载的全过程是怎样的?
- JAVA的类加载器:父子关系AppClassLoader <- ExtClassLoader <- Bootstrap classloader
- Bootstrap classloader 加载java基础类。默认负责加载%JAVA_HOME%lib下的jar包和class文件;
- Extention classloader 加载JAVA_HONE/ext下的jar包和class类。可通过-D java.ext.dirs另行指定目录;
- AppClassLoader 加载CLASSPATH应用下的Jar包,是自定义加载器的父类。可通过-D java.class.path另行指定目录,是系统类加载器,线程上下文加载器;
- BootStrap classloader 由C++开发,是JVA虚拟机的一部分,本身不是JAVA类。
- string , Int等基础类出BootStrap classloader加载。
类加载过程:加载-》连接-》初始化
加载:把Java的字节码数据加载到JVM内存当中,并映射成JVM认可的数据结构。
连接:分为三个小的阶段:
- 验证:检查加载到的字节信息是否符合JVM规范。
- 准备:创建类或接口的静态变量,并赋初始值半初始化状态
- 解析:把符号引用转为直接引用
初始化: 与JVM机制无关了,是自己的代码执行过程。
什么是双亲委派机制?
JAVA的类加载器: AppClassloader -> ExtClassloader ->BootStrap Classloader
每种类加载器都有他自己的加载目录。
JAVA中的类加载器: AppClassLoader, ExtClassLoader -> URLClassLoader ->SecureClassLoader -> ClassLoader
每个类加载器对他加载过的类,都是有一个缓存的。
双亲委派:向上委托在缓存中查找直到最底层的Bootstrap classloader,还没有找到就向下委托在加载器的路径下查找加载直到返回classpath路径。
有什么作用?
作用:保护JAVA底层的类不会被应用程序覆盖。
一个对象从加载到JVM,再到被GC清除,都经历了什么过程?
- 用户创建一个对象,首先通过内存中class point找到方法区中的类型信息(元数据区中的class)。
- 然后在JVM中实例化对象,在堆中开辟空间,半初始化对象(存在指令重排)。
- 对象会分配在堆内存中新生代Eden.然后经过一次Minor GC,对象如果存活就会进入s区,在后续每次GC中,如果对象一直存活就会在S区来回拷贝,每移动一次年龄加一,最大年龄为15,超过一定年龄就会移入老年代。
- 直到方法执行结束后栈中指针先移除掉﹔
- 堆中的对象经过Full GC,则会被标记为垃圾,然后被GC进程清除。
JAVA内存模型
- JVM Stack:存放执行java方法的栈帧;
- Native Stack:存放执行本地方法的栈帧;
- 程序计数器:每一个线程执行到哪一步,在JVM Stack记录指令的内存地址,在Native Stack中永远为0;
- 栈:定义变量的指针,每执行一个java方法都会生成一个栈帧
- 堆:存放所有的对象数组;
- 新生代:占堆内存的三分之一,默认对象年龄达到15以后进入老年代;
- 老年代:占堆内存的三分之二;
- Eden:占新生代的十分之八;
- Survivor:分S1,S2分别十分之一,
- 元数据区(MetaSpace):存放常量、静态变量等,1.8以前属于堆内存,1.8以后移出到操作系统中;
- DirectBuffer:JDK1.4的NIO中引用,调用native本地的方法去操作JVM以外的操作系统的内存。
JVM有哪些垃圾回收器?他们都是怎么工作的?什么是STW?他都发生在哪些阶段?什么是三色标记?如何解决错标记和漏标记的问题?为什么要设计这么多的垃圾回收器?
STW: Stop-The-World。是在垃圾回收算法执行过程当中,需要将JVM内存冻结的一种状态。在STW状态下,JAVA的所有线程都是停止执行的-GC线程除外,native方法可以执行,但是不能与JVM交互。GC各种算法优化的重点,就是减少STW,同时这也是JVM调优的重点。
- 分代算法
- Serial:串行 整体过程简单,需要GC时暂停,GC结束后继续运行;属于早期垃圾回收期,只有一个线程执行,多CPU架构下性能严重不足。
- Parallel:并行 在串行的基础上,增加多线程GC。PS+PO组合是1.8默认的垃圾回收器。多CPU架构下,性能更高。
- CMS:Concurrent Mark Sweep 核心思想是将STW打散,让一部分GC线程与用户线程并发执行,整个GC过程分为四个部分:
- 初始标记阶段:STW只标记出根对象直接引用的对线。
- 并发标记:继续标记其他对象,与应用程序是并发执行。
- 重新标记:STW对并发执行阶段的对象进行重新标记。
- 并发清除:并行。将产生的垃圾清除。清除过程中,应用程序又会不断产生新的垃圾,叫做浮动垃圾,留到下一次GC过程中清除。
- 不分代算法
- G1 Garbage First 垃圾优先:他的内存模型是实际不分代,但是逻辑上是分代的。在内存模型中,对于堆内存就不再分老年代和新生代,而是划分成一个一个的小内存块,叫做Region。每个Region可以隶属于不同的年代。GC分为四个阶段:
- 第一:初始标记标记出GCRoot直接引用的对象。STW
- 第二:标记Region,通过RSet标记出上一个阶段标记的Region引用到的Old区Region,
- 第三∶并发标记阶段:跟CMS的步骤是差不多的。只是遍历的范围不再是整个Old区,而只需要遍历第二步标记出来的Region。
- 第四:重新标记:跟CMK中的重新标记过程是差不多的。
- 第五:垃圾清理:与CMS不同的是,G1可以采用拷贝算法,直接将整个Region中的对象拷贝到另一个Region。而这个阶段,G1只选择垃圾较多的Region来清理,并不是完全清理。|
- shennandoah:G1机制类似,了解即可。
- ZGC:与内存大小无关,完全不分代。
- G1 Garbage First 垃圾优先:他的内存模型是实际不分代,但是逻辑上是分代的。在内存模型中,对于堆内存就不再分老年代和新生代,而是划分成一个一个的小内存块,叫做Region。每个Region可以隶属于不同的年代。GC分为四个阶段:
CMS的核心算法就是三色标记。
三色标记:是一种逻辑上的抽象。将每个内存对象分成三种颜色:黑色:表示自己和成员变量都已经标记完毕。灰色:自己标记完了,但是成员变量还没有完全标记完。白色:自己未标记完。
怎么确定一个对象到底是不是垃圾?什么是GC Root?
有两种定位垃圾的方式:
- 引用计数:这种方式是给堆内存当中的每个对象记录一个引用个数。引用个数为O的就认为是垃圾。这是早期JDK中使用的方式。引用计数无法解决循环引用的问题。
- 根可达算法:这种方式是在内存中,从引用根对象向下一直找引用,找不到的对象就是垃圾。
哪些是GC
Root? Stack ->JVM Stack, Native Stack,class类,run-time constant pool 常量池, static reference静态变量。
JVM有哪些垃圾回收算法?
- MarkSweep标记清除算法:
这个算法分为两个阶段,标记阶段:把垃圾内存标记出来,清除阶段:直接将垃圾内存回收。这种算法是比较简单的,但是有个很严重的问题,就是会产生大量的内存碎片,因为 - Copying拷贝算法:
为了解决标记清除算法的内存碎片问题,就产生了拷贝算法。拷贝算法将内存分为大小相等的两半,每次只使用其中一半。垃圾回收时,将当前这一块的存活对象全部拷贝到另一半,然后当前这一半内存就可以直接清除。
这种算法没有内存碎片,但是他的问题就在于浪费空间。而且,他的效率跟存货对象的个数有关。 - MarkCompack标记压缩算法:
为了解决拷贝算法的缺陷,就提出了标记压缩算法。这种算法在标记阶段跟标记清除算法是一样的,但是在完成标记之后,不是直接清理垃圾内存,而是将存活对象往一端移动,然后将端边界以外的所有内存直接清除。
这三种算法各有利弊,各自有各自的适合场景。
JVM中哪些可以作为gc root
什么是gc root,NM在进行垃圾回收时,需要找到“垃圾”对象,也就是没有被引用的对象,但是直接找“垃圾”对象是比较耗时的,所以反过来,先找“非垃圾”对象,也就是正常对象,那么就需要从某些“根”开始去找,根据这些“根”的引用路径找到正常对象,而这些“根”有一个特征,就是它只会引用其他对象,而不会被其他对象引用,例如:栈中的本地变量、方法区中的静态变量、本地方法栈中的变量、正在运行的线程等可以作为gc root。
如何进行JVM调优?JVM参数有哪些?怎么查看一个JAVA进程的JVM参数?谈谈你了解的JVM参数。如果一个java程序每次运行一段时间后,就变得非常卡顿,你准备如何对他进行优化?
JVM调优主要就是通过定制JVM运行参数来提高JAVA应用程度的运行数据
JVM参数大致可以分为三类:
- 标注指令:-开头,这些是所有的HotSpot都支持的参数。|
- 非标准指令:-X开头,这些指令通常是跟特定的HotSpot版本对应的。可以用java -X打印出来。
- 不稳定参数:-XX开头,这一类参数是跟特定HotSpot版本对应的,并且变化非常大。详细的文档资料非常少。
java -XX:+PrintCommandLineFlags :查看当前命令的不稳定指令。
java -XX:+PrintFlagslnitial :查看所有不稳定指令的默认值。
java -XX:+PrintFlagsFinal:查看所有不稳定指令最终生效的实际值。
什么是字节码?采用字节码的好处是什么?
java中的编译器和解释器:
- Java中引入了虚拟机的概念,即在机器和编译程序之间加入了一层抽象的虚拟的机器。这台虚拟的机器在任何平台上都提供给编译程序一个的共同的接口。
- 编译程序只需要面向虚拟机,生成虚拟机能够理解的代码,然后由解释器来将虚拟机代码转换为特定系统的机器码执行。在Java中,这种供虚拟机理解的代码叫做字节码(即扩展名为.class的文件),它不面向任何特定的处理器,只面向虚拟机。
- 每一种平台的解释器是不同的,但是实现的虚拟机是相同的。Java源程序经过编译器编译后变成字节码,字节码由虚拟机解释执行,虚拟机将每一条要执行的字节码送给解释器,解释器将其翻译成特定机器上的机器码,然后在特定的机器上运行。这也就是解释了Java的编译与解释并存的特点。
- Java源代码——>编译器—->jvm可执行的Java字节码(即虚拟指令)—->jvm—->jvm中解释器——>机器可执行的二进制机器码——>程序运行。
采用字节码的好处:
Java语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以Java程序运行时比较高效,而且,由于字节码并不专对一种特定的机器,因此,Java程序无须重新编译便可在多种不同的计算机上运行。