本文转载自微信公众号「安琪拉的博客」,作者安琪拉。转载本文请联系安琪拉的博客公众号。
面试官:上次我们公司搞了个专场面试,来了一百多候选人,现场很热闹,你怎么没来?
安琪拉: 天气太热,你们公司离地铁站又比较远,以我的能力,面完肯定是抢不到共享单车的,所以就不凑这个热闹了。
面试官:你这是什么意思?
安琪拉: 没什么意思。。。哎,我等不及了,快开始吧。
面试官:你简历上写熟悉多线程,你能给我讲为什么要用多线程吗?多线程有什么好处?最好能给我举个你工作中的实际例子。
安琪拉: 比如: 用户查看在支付宝买的电影票的时候,顺便在页面下半部分推荐给用户一些最近的热门电影。
比如安琪拉看自己买的“寂静之地2”的时候,页面底部同时推荐给我“速度与激情9”,放个预告片、影片介绍啥的。
面试官: 这就完了? 然后呢,详细讲讲怎么用到多线程的?
安琪拉: 如果不使用多线程,支付宝服务端先查询用户的购票信息,查询完之后再查询热门电影推荐信息,这样串行效率很慢。
改成多线程,同时进行二个请求的查询,查询完成把结果组装,展示给用户。
面试官:那如果查询热门电影推荐失败了,或者查询推荐信息很慢,岂不是也很影响用户查看自己买的票,如果这个时候用户着急看电影,一直出不来,岂不是3.25啦。
安琪拉: 我们会把查票信息和查询电影推荐信息请求放在二个不同的线程池,查询热门电影推荐这个请求做成弱依赖,也就是查询热门电影推荐失败或者超时也不会影响到查询电影票信息。
面试官:能写个伪代码说明下吗?
安琪拉: 可以,帮我拿张A4纸,顺便把你笔借我一下。(下面涉及Future、线程池的代码看不懂没关系,后面并发系列介绍完回过头来看也可以)
- //查询票信息
- Future getTicketFuture = ticketHandlePool.submit(()->{
- //查询票信息
- doQuery();
- });
- //查询推荐电影
- Future recMovieFuture = recMovieHandlePool.submit(()->{
- //查询推荐电影
- try {
- doQuery();
- } catch (Exception ex) {
- //异常捕获记录
- logger.warn("信息", ex);
- }
- });
- //获取票查询结果
- try {
- recMovieFuture.get(2, TimeUnit.SECONDS);
- } catch (Exception e) {
- //弱依赖: 超时、中断等异常只是warn级别记录,任务取消,不抛出异常
- logger.warn("信息", e);
- recMovieFuture.cancel(true);
- }
- //获取推荐信息查询结果强依赖
- try {
- getTicketFuture.get(3, TimeUnit.SECONDS);
- } catch (Exception e) {
- logger.error("信息", e);
- recMovieFuture.cancel(true);
- throw new ***Exception(e, "获取票信息异常");
- }
面试官:那你给我总结一下并发编程的优势吧。
安琪拉: 【看来实践环节过了,开始上八股文了。】
嗯,刚才我们也看到了,并发能提升程序执行的效率,充分利用CPU,特别是对于多核,IO密集型(经常需要等待I/O,多线程可以充分利用CPU资源),并发也是一种设计,在某些多任务处理,或者一个大任务需要拆分成很多个子任务的场景,并发一方面能提升执行效率,另一方面能清晰的表达程序设计者的意图。
【这一波方法论应该能让面试官抖一抖】
面试官:那并发编程有什么风险呢?
安琪拉: 总的来说有这么几个:
- 线程频繁上下文切换,会有性能损耗;
- 共享数据多线程访问,如果不加控制,可能会出现线程安全问题;
面试官:关于线程安全相关的,你能我有几个问题想问你。
安琪拉: 请出题。
面试官:你给我讲讲JVM运行时数据区域的划分吗?
安琪拉: 【它来了,它终于还是来了】
这个给个小提示,有时候我们会把JVM的运行时数据区域和Java内存模型搞混,面试题二个一般都会问到。
- JVM的运行时数据区就是堆、栈这些,规定运行时内存(含寄存器) 分成哪几块,起什么作用;
- Java内存模型是为了Java语言的跨平台表现一致性,屏蔽硬件和操作系统实现提出的规范,例如规定了线程和主内存之间的抽象关系,既然是规范,只会规定概念,具体实现依赖不同平台的JVM虚拟机的实现。
其实日常写代码心里有总体概念就好了,不需要像做研究一样的深入实现细节,除非面试的是JVM虚拟机开发岗这种。
如下如所示,就是JVM运行时数据区
面试官:那我们来一块一块区域的讲。你先给我讲下什么是虚拟机栈(JVM Stacks)?
安琪拉: 要讲虚拟机栈,我们先要知道栈是什么时候创建的?因为栈是线程私有的,栈的生命周期跟线程一致,所以记住栈是跟线程绑定在一起的就好了,栈中存的内容也是线程运行需要用到的,看我们上图画的,栈是由一个个栈帧组成的,栈帧里面存放局部变量表、操作栈、动态链接、方法返回地址。
面试官:详细讲讲这四个玩意呗。
安琪拉: 我得写段代码演示一下。
- public static void main(String[] args) {
- String str = dance("angela", 3);
- }
- private static String dance(String name, int count) {
- String result = name + ":" + count;
- return result;
- }
每个方法在执行的同时都会创建一个栈帧(Stack Frame),栈帧是方法运行时的基本数据单元,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
局部变量表
main方法执行,会启动一个线程,这时候主线程的虚拟栈中会压入一个栈帧,调用dance 方法时再会压入一个栈帧,存放name、count 等数据,name、count、result就是存在在局部变量表中,局部变量表是存放方法参数和局部变量的区域。
如果是非静态方法,则在局部变量表 index[0] 位置上存储的是方法所属对象实例的引用,一个引用变量占 4 个字节,随后存储的是方法参数和局部变量。
操作数栈
操作数非常有意思,这里要讲到程序执行原理,String result = name + ":" + count; 这行代码分成好几个步骤,简化就是:取数、执行、存数。取name压入操作数栈、取count压入操作数栈,然后弹栈2次,执行拼接动作,把结果压栈,存入局部变量表。
所以为什么说JVM 的执行引擎是基于栈的执行引擎,就是这个原因,这里的栈就是操作数栈。
后面的系列讲到volatile的时候会介绍load、store等相关指令。
字节码指令中的 STORE 指令就是将操作栈中计算完成的结果写回局部变量表的存储空间内。
再说动态链接和方法返回地址。
动态链接
每个栈帧中包含一个在常量池中对当前方法的引用, 目的是支持方法调用过程的动态连接。
可能有点绕,这部分深入说要讲类的编译、加载、链接的过程,Class 文件中存放了大量的符号引用,字节码中的方法调用指令就是以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接,比如反射时invokedynamic 调用的,在运行时常量池存放的当前方法的引用是动态生成的,运行时可以动态链接。
方法返回地址
方法执行完,执行弹栈操作,弹出当前栈帧,方法返回地址就是方法执行之后(弹栈之后)下一步要执行的地址。
面试官:那本地方法栈和你说的虚拟机栈什么区别?
安琪拉: 本地方法栈(Native Method Stack)与虚拟机栈很相似,它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。
比如Thread类的 start0 方法,就是Native方法。
- private native void start0();
Sun HotSpot 虚拟机直接把本地方法栈和虚拟机栈合二为一。
面试官:那 Java 堆呢?
安琪拉: Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,几乎所有的对象实例都在这里分配内存。
面试官:关于Java堆的内存回收你能讲一下吗?
安琪拉: 堆是垃圾收集器(GC)管理的主要区域,因此很多时候也被称做“GC堆”(Garbage Collected Heap)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代;再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等。
面试官:方法区呢?你知道JVM规范中JDK8之前和之后方法区的变化吗?
安琪拉: 方法区是JVM规范中的说话,具体到不同JVM,有不同的实现,以最流行的Sun Hotspot为例,JDK8 之前,Hotspot 中方法区的实现是永久代(Perm),JDK8 开始使用元空间(Metaspace),以前永久代的字符串常量移至堆内存,其他内容移至元空间,元空间直接在本地内存分配。
面试官:方法区,或者说它的实现元空间是做什么的?
安琪拉: 方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
其实从底层物理存储来讲,跟堆是都是在内存中,Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
面试官:为什么要使用元空间取代永久代的实现?
安琪拉: 主要有几点原因:
- 字符串存在永久代中,容易出现性能问题和内存溢出。由于 PermGen(永久代) 经常会溢出,引发 java.lang.OutOfMemoryError: PermGen 问题,所以 JVM 的开发者希望这一块内存可以更灵活地被管理,不要再经常出现这样的 OOM;
- 移除 PermGen 可以促进 HotSpot JVM 与 JRockit VM 的融合,因为 JRockit 没有永久代。
面试官:我看你图中画了元数据区的常量池,JVM中常量池能详细讲讲吗?
安琪拉: 首先明确一点,JVM中有三种常量池:
- JVM常量池
- 运行时常量池
字符串常量池
然后我们分别说下三种的区别和联系,JVM常量池也叫class文件常量池,是class文件的一部分,用于保存编译时确定的数据。
最关键的是编译期三个字。
这个我们写个Java程序,反编译一下,看字节码就知道了,如下图,常量池的符号引用都列出来了。
#1 引用 #5.#25 什么意思呢,我们看#5 是 java/lang/Object, #25 是 #8:#9 // "":()V
其实就是调用初始化方法,引用方法名称、返回值和继承的类(任何类都继承Object类,所以引用了 java/lang/Object)
常量池存了一堆符号引用。
在Class编译加载后Class常量池加载到运行时常量池,运行时常量池存储在元空间。JVM在执行某个类的时候,会经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中。
最后一个就是字符串常量池(String Constant Pool),很多人以为上图反编译的Class常量池中的字符串就存储在字符串常量池,网上很多博客二者也搞混了,Class常量池只在编译期间起作用,编译期间确定了一堆引用关系,比如: 类和方法的全限定名、字段的名称和描述符 、方法的名称和描述符、文本字符串。
存储在哪?
字符串常量池存储在堆上,在JDK6.0及之前版本,字符串常量池是放在Perm Gen区(也就是方法区)中。
怎么存储?
在HotSpot VM里实现常量池的是一个StringTable类,它是一个Hash表,默认值大小长度是1009;这个StringTable只有一份,被所有的类共享。字符串常量由一个一个字符组成,放在了StringTable上。
存储什么?
字符串常量池中的字符串只存在一份!
- 在JDK6.0及之前版本中,字符串常量池(String Pool)里放的都是字符串常量;
- 在JDK7.0中,字符串常量池(String Pool)中也可以存放放于堆内的字符串对象的引用。
面试官:你说JDK7.0后字符串常量池(String Pool)中也可以存放放于堆内的字符串对象的引用,能举个例子吗?
安琪拉: 在JDK 7下,当执行String.intern();时,因为常量池中没有“like”这个字符串,所以会在常量池中生成一个对堆中的“like”的引用(注意这里是引用 ,就是这个区别于JDK 1.6的地方。在JDK1.6下是生成原字符串的拷贝)
- public void stringTest() {
- String str1 = "follow";
- String str2 = "angela";
- String str3 = new String("like");
- str3.intern();
- }
如下图,JDK1.6 String.intern()的操作,生成原字符串“like”的拷贝。
JDK1.7 如下图:生成一个对堆中的“like”的引用
面试官:那你给我讲讲前面说的Java内存模型吧,最好能写点实际工程代码,说明Java内存模型在实际项目的用处。
安琪拉: 要不还是下次吧,今天有点晚了,你们公司离地铁远,我要早点去抢共享单车,这题就留给二面面试官吧。
面试官:也行,那你先回去吧,有消息我通知你。
安琪拉: 好嘞,回见。