Java多线程

关于Java多线程的面试题。

介绍一下Java内存区域(运行时数据区)

线程私有的:

  • 程序计数器
    • 计算机组成原理:取下一条将要执行的字节码指令
    • 为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
    • 程序计数器是唯一一个不会出现OutOfMemoryError的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
  • Java虚拟机栈
    • 与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型。
    • Java内存可以粗糙的区分为堆内存(Heap)和栈内存(Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。(实际上,Java虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。)
    • 局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、long、float、double)、对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
    • Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。
      • StackOverFlowError: 若Java虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候,就抛出StackOverFlowError异常。
      • OutOfMemoryError: 若Java虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出OutOfMemoryError异常。
    • Java 虚拟机栈也是线程私有的,每个线程都有各自的Java虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。
  • 本地方法栈
    • 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
    • 本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
    • 方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。
      线程共享的:
    • Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
    • Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap).从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代:再细致一点有:Eden空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。
    • 在 JDK 1.8中移除整个永久代,取而代之的是一个叫元空间(Metaspace)的区域(永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制)。
  • 方法区
    • 方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
    • HotSpot 虚拟机中方法区也常被称为 “永久代”,本质上两者并不等价。仅仅是因为 HotSpot 虚拟机设计团队用永久代来实现方法区而已,这样 HotSpot 虚拟机的垃圾收集器就可以像管理 Java 堆一样管理这部分内存了。但是这并不是一个好主意,因为这样更容易遇到内存溢出问题。
    • 相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。
    • 运行时常量池
      • 运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用)
      • 既然运行时常量池时方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
      • JDK1.7及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。
  • 直接内存
    • 直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 异常出现。
    • JDK1.4中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
    • 本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

Java对象创建过程(五步)

  1. 类加载检查
    • 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
  2. 分配内存
    • 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定(而 Java 堆内存是否规整,取决于 GC 收集器的算法是”标记-清除”,还是”标记-整理”(也称作”标记-压缩”),值得注意的是,复制算法内存也是规整的),而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
      • 指针碰撞
        • 堆内存规整(即没有内存碎片)的情况下
        • 原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界值指针,只需要向着没有用过的内存方向将该指针移动对象内存大小位置即可。
        • GC收集器:Serial、ParNew
      • 空闲列表
        • 堆内存不规整的情况下
        • 虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块足够大的内存地来划分给对象实例,最后更新列表记录。
        • GC收集器:CMS
    • 内存分配并发问题
      • 在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
        • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
        • TLAB: 为每一个线程预先在Eden区分配一块儿内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配
  3. 初始化零值
    • 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
  4. 设置对象头
    • 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
  5. 执行init方法
    • 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init/> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init/> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为3块区域:对象头、实例数据和对齐填充。

  • Hotspot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的自身运行时数据(哈希码、GC分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。
  • 实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
  • 对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

对象的访问定位的两种方式(句柄和直接指针)

建立对象就是为了使用对象,我们的Java程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式有虚拟机实现而定,目前主流的访问方式有①使用句柄和②直接指针两种:

  1. 句柄: 如果使用句柄的话,那么Java堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;
  1. 直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference 中存储的直接就是对象的地址。

这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

String类和常量池

String对象的两种创建方式

String str1 = "abcd";
String str2 = new String("abcd");
System.out.println(str1==str2); //false

这两种不同的创建方法是有差别的,第一种方式是在常量池中拿对象,第二种方式是直接在堆内存空间创建一个新的对象。只要使用new方法,便需要创建新的对象。

String类型的常量池比较特殊。它的主要使用方法有两种

  • 直接使用双引号声明出来的 String 对象会直接存储在常量池中。
  • 如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern 方法。String.intern() 是一个 Native 方法,它的作用是:如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,则在常量池中创建与此 String 内容相同的字符串,并返回常量池中创建的字符串的引用。
    String s1 = new String("计算机");
    String s2 = s1.intern();
    String s3 = "计算机";
    System.out.println(s2); //计算机
    System.out.println(s1 == s2); //false,因为一个是堆内存中的String对象一个是常量池中的String对象,
    System.out.println(s3 == s2); //true,因为两个都是常量池中的String对象

String字符串拼接

尽量避免多个字符串拼接,因为这样会重新创建对象。如果需要改变字符串的话,可以使用 StringBuilder 或者 StringBuffer。

String str1 = "str";
String str2 = "ing";

String str3 = "str" + "ing"; //常量池中的对象
String str4 = str1 + str2; //在堆上创建的新的对象	  
String str5 = "string"; //常量池中的对象
System.out.println(str3 == str4); //false
System.out.println(str3 == str5); //true
System.out.println(str4 == str5); //false

String s1 = new String(“abc”);这句话创建了几个对象?

创建了两个对象。

String s1 = new String("abc");// 堆内存的地址值
String s2 = "abc";
System.out.println(s1 == s2);// 输出false,因为一个是堆内存,一个是常量池的内存,故两者是不同的。
System.out.println(s1.equals(s2));// 输出true

先有字符串”abc”放入常量池,然后 new 了一份字符串”abc”放入Java堆(字符串常量”abc”在编译期就已经确定放入常量池,而 Java 堆上的”abc”是在运行期初始化阶段才确定),然后 Java 栈的 str1 指向Java堆上的”abc”。

8种基本类型的包装类和常量池

  • Java 基本类型的包装类的大部分都实现了常量池技术,即Byte,Short,Integer,Long,Character,Boolean;这5种包装类默认创建了数值[-128,127]的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。
  • 两种浮点数类型的包装类 Float,Double 并没有实现常量池技术。
    Integer i1 = 33;
    Integer i2 = 33;
    System.out.println(i1 == i2); // 输出true
    Integer i11 = 333;
    Integer i22 = 333;
    System.out.println(i11 == i22); // 输出false
    Double i3 = 1.2;
    Double i4 = 1.2;
    System.out.println(i3 == i4); // 输出false
    Integer 缓存源代码:
    /**
    *此方法将始终缓存-128到127(包括端点)范围内的值,并可以缓存此范围之外的其他值。
    */
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }
  • Integer i1=40;Java 在编译的时候会直接将代码封装成Integer i1=Integer.valueOf(40);,从而使用常量池中的对象。
  • Integer i1 = new Integer(40);这种情况下会创建新的对象。
    Integer i1 = 40;
    Integer i2 = new Integer(40);
    System.out.println(i1==i2);//输出false
    Integer i1 = 40;
    Integer i2 = 40;
    Integer i3 = 0;
    Integer i4 = new Integer(40);
    Integer i5 = new Integer(40);
    Integer i6 = new Integer(0);
    
    System.out.println("i1=i2   " + (i1 == i2));
    System.out.println("i1=i2+i3   " + (i1 == i2 + i3));
    System.out.println("i1=i4   " + (i1 == i4));
    System.out.println("i4=i5   " + (i4 == i5));
    System.out.println("i4=i5+i6   " + (i4 == i5 + i6));   
    System.out.println("40=i5+i6   " + (40 == i5 + i6));
    i1=i2   true
    i1=i2+i3   true
    i1=i4   false
    i4=i5   false
    i4=i5+i6   true
    40=i5+i6   true
    语句i4 == i5 + i6,因为+这个操作符不适用于Integer对象,首先i5和i6进行自动拆箱操作,进行数值相加,即i4 == 40。然后Integer对象无法与数值进行直接比较,所以i4自动拆箱转为int值40,最终这条语句转为40 == 40进行数值比较。

为什么使用多线程呢?

  • 计算机底层:线程就像是轻量级的进程。线程是程序执行的最小单位,线程间的调度开销远远小于进程间调度。另外多核CPU意味着多个线程可以同时运行,进一步减小了线程上下文切换的开销。
  • 互联网发展趋势:现在的系统动不动就需要百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。

    单核时代,主要是提高CPU与IO设备的综合利用率。多核时代,主要是提高CPU的利用率,多核正好对应多线程。

多线程可能带来的问题?

虽然并发编程的初衷是为了提高程序的运算速度,但是带来了其他的问题:内存泄露、上下文切换、死锁还有受限于硬件与软件的资源闲置问题。

线程的生命周期

  • NEW 尚未启动
  • RUNNABLE 正在执行中
  • BLOCKED 阻塞的(被同步锁或者IO锁阻塞)
  • WAITING 永久等待状态
  • TIMED_WAITING 等待指定的时间重新被唤醒的状态
  • TERMINATED 执行完成

上下文切换

操作系统,时间片轮转。

复习操作系统,死锁的四个条件

  • 互斥条件:该资源任意时刻只由一个线程占用。
  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源不释放。
  • 不剥夺条件:线程已获得的资源在未使用完前不能被其他线程强行掠夺,只能由自己使用完了主动释放。
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

如何避免线程死锁?

  1. 破坏互斥条件:没办法破坏,因为用锁本来就是让他们互斥(临界资源需要互斥访问)。
  2. 破坏请求与保持条件:一次性申请所有的资源。
  3. 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源,如果申请不到,就主动释放它占有的资源。
  4. 破坏循环等待条件:按顺序申请资源,释放资源则反序释放。

说说sleep()方法和wait()方法区别和共同点?

  • 最主要的区别:sleep方法没有释放锁,wait方法释放了锁。
  • 两者都可以暂定线程的执行。
  • wait通常用于线程间的交互/通讯,sleep通常用于暂停执行。
  • wait()方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()或者notifyAll()方法。sleep()方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)超时后线程会自动苏醒。

为什么我们调用start()方法时会执行run()方法,为什么我们不能直接调用run()方法?

new一个Thread,线程进入了新建状态;调用start()方法,会启动一个线程并使线程进入就绪状态,当分配到时间片后就能开始运行了。start()会执行线程的相应准备工作,然后自动执行run()方法的内容,这是真正的多线程工作。而直接执行run()方法,会把run方法当做一个main线程下的普通方法去执行,并不会在某个线程中执行它,所以并不是多线程工作。

synchronized关键字

synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被他修饰的方法或代码块在任意时刻只有一个线程执行。
另外,在Java早期的版本中,synchronized属于重锁级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,Java的线程是映射到操作系统的原生线程之上的,如果要挂起或者唤醒一个线程,都需要操作系统的帮忙,而操作系统实现线程之间的切换需要从用户态转换到内核态,这个状态之间的转换需要相对较长的时间,时间成本相对较高。
但是,在Java6之后Java官方从JVM层对synchronized较大优化,所以现在sychronized锁效率优化得很不错了。JDK1.6对锁的实现引入来大量的优化,如自旋锁、锁消除、锁粗化、偏向锁、轻量锁等技术来减少锁操作的开销。

synchronized关键词最主要的三种使用方式

  • 修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。
  • 修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员(static表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。所以一个线程A调用一个实例对象的非静态synchronized方法,而线程B需要调用这个实例对象所属类的synchronized方法,是允许的,不会发生互斥现象,因为访问静态sychronized方法占用的锁是当前实例对象锁。
  • 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

sychronized关键词加到static静态方法和sychronized(class)代码块上都是给Class类上锁。synchronized关键字加到实例方法上是给对象实例上锁。尽量不要使用sychronized(String a),因为在JVM中,字符串常量池具有缓存功能。

双重校验锁实现兑现单例(线程安全)

public class Singleton {
	private volatile static Singleton uniqueInstance;
	private Singleton() {}

	public static Singleton getUniqueInstance() {
 	// 先判断对象是否已经实例过,没有实例化过才进⼊加锁代码
 	if (uniqueInstance == null) {
 	// 类对象加锁
 		synchronized (Singleton.class) {
 			if (uniqueInstance == null) {
 				uniqueInstance = new Singleton();
 			}
 		}
 	}
 	return uniqueInstance;
 	}
 }

uniqueInstance使用volatile关键词修饰也是很有必要的,uniqueInstance = new Singleton(); 这段代码其实分为三步执行:

  1. 为uniqueInstance分配内存空间
  2. 初始化uniqueInstance
  3. 将uniqueInstance指向分配的内存空间
    但是由于JVM具有指令重排的特性,执行顺序有可能变成1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如线程T1执行了1和3,此时T2调用getUniqueInstance()发现uniqueInstance不为空,因此返回uniqueInstance,但此时uniqueInstance还未初始化。
    使用volatile可以禁止JVM指令重拍,保证在多线程环境下也能正常运行。

谈谈sychronized和ReentranLock的区别?

  1. 两者都是可重入锁:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的化,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。

  2. sychronized依赖于JVM而ReentrantLock依赖于API(需要lock()和unlock()方法配合try/finally语句来完成)

  3. ReentrantLock比synchronized增加了一些高级功能

    • 等待可中断
      • ReentrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
    • 可实现公平锁
      • ReentrantLock可以指定是非公平锁或者是公平锁,而sychronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认是非公平的,可以通过ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否公平。
    • 可实现选择性通知(锁可以绑定多个条件)
      • sychronized关键字可以与wait()和notify()/notifyAll()方法相结合实现等待/通知机制。ReentrantLock当然也可以有实现,但是需要借助Condition接口和NewCondition()方法。Condition是JDK1.5之后才有的,它就有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。在使用notify()/notifyAll()方法进行通知时,被通知的线程是由JVM选择的,而ReentrantLock类结合Condition实例可以实现“选择性通知”,这个功能非常重要,是Condition接口默认提供的。而synchronized关键字就相当于整个Lock对象只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程,这样会造成很大的效率问题,而Condition实例的signalAll()方法只会唤醒注册在该Condition实例中的所有等待线程。
    • 性能已经不是选择标准了

来源

Guide哥 | JavaGuide面试突击版