一:线程间是怎么通信的,通过调用几个方法来交互的?
线程是通过wait , notify等方法相互作用进行协作通信;
wait()方法使得当前线程必须要等待,直到到另外一个线程调用notify()或者notifyAll()方法唤醒
wait()和notify()方法要求在调用时线程已经获得了对象锁,因此对这两个方法的调用需要在 synchronized修饰的方法或代码块中。
Wait,notify,notifyAll都必须在synchronized修饰的方法或代码块中使用,都属于Object的方法,可以被所有类继承,都是final修饰的方法,不能通过子类重写去改变他们的行为
二、线程的生命周期及五种基本状态?
1. 新建(new):新创建了一个线程对象。
2. 可运行(runnable):线程对象创建后,当调用线程对象的 start()方
法,该线程处于就绪状态,等待被线程调度选中,获取cpu的使用权。
3. 运行(running):可运行状态(runnable)的线程获得了cpu时间片
(timeslice),执行程序代码。注:就绪状态是进入到运行状态的唯一入
口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;
4. 阻塞(block):处于运行状态中的线程由于某种原因,暂时放弃对 CPU
的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才 有
机会再次被 CPU 调用以进入到运行状态。
阻塞的情况分三种:
(一). 等待阻塞:运行状态中的线程执行 wait()方法,JVM会把该线程放入等待
队列(waitting queue)中,使本线程进入到等待阻塞状态;
(二). 同步阻塞:线程在获取 synchronized 同步锁失败(因为锁被其它线程所占
用),则JVM会把该线程放入锁池(lock pool)中,线程会进入同步阻塞状态;
(三). 其他阻塞: 通过调用线程的 sleep()或 join()或发出了 I/O 请求时,线程会
进入到阻塞状态。当 sleep()状态超时、join()等待线程终止或者超时、或者 I/O
处理完毕时,线程重新转入就绪状态。
5. 死亡(dead):线程run()、main()方法执行结束,或者因异常退出了
run()方法,则该线程结束生命周期。死亡的线程不可再次复生。
三、请说出与线程同步以及线程调度相关的方法。
(1) wait():使一个线程处于等待(阻塞)状态,并且释放所持有的对象的
锁;condition.await();
(2)sleep():使一个正在运行的线程处于睡眠状态,是一个静态方法,调用此
方法要处理 InterruptedException 异常;
(3)notify():唤醒一个处于等待状态的线程,当然在调用此方法的时候,并不
能确切的唤醒某一个等待状态的线程,而是由 JVM 确定唤醒哪个线程,而且与
优先级无关;condition.signal();condition.signalAll();
https://blog.csdn.net/sophia__yu/article/details/84502578
(4)notityAll():唤醒所有处于等待状态的线程,该方法并不是将对象的锁给
所有线程,而是让它们竞争,只有获得锁的线程才能进入就绪状态;
四、并发编程有什么缺点
并发编程的目的就是为了能提高程序的执行效率,提高程序运行速度,但是并发
编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如
:内存泄漏、上下文切换、线程安全、死锁等问题。
五、并发编程三要素是什么?在 Java 程序中怎么保证多线程的运行安全?
并发编程三要素(线程的安全性问题体现在):
原子性:原子,即一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。
可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。(synchronized,volatile)
有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)
出现线程安全问题的原因:
1、线程切换带来的原子性问题
2、缓存导致的可见性问题
3、编译优化带来的有序性问题
解决办法:
JDK Atomic开头的原子类、synchronized、LOCK,可以解决原子性问题
synchronized、volatile、LOCK,可以解决可见性问题
Happens-Before 规则可以解决有序性问题
六、synchronized 的作用及优化介绍
在 Java 中,synchronized 关键字是用来控制线程同步的,就是在多线程的环
境下,控制 synchronized 代码段不被多个线程同时执行。
另外,在 Java 早期版本中,synchronized属于重量级锁,效率低下,因为监视
器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的
线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需
要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内
核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是
为什么早期的 synchronized 效率低的原因。在 Java 6 之后 Java 官方
对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率
也优化得很不错了。JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性
自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
通过JDK 反汇编指令 javap -c -v SynchronizedDemo
可以看出在执行同步代码块之前之后都有一个monitor字样,其中前面的是
monitorenter,后面的是离开monitorexit,不难想象一个线程也执行同步代码
块,首先要获取锁,而获取锁的过程就是monitorenter ,在执行完代码块之
后,要释放锁,释放锁就是执行monitorexit指令。
七、Java内存模型
线程之间主要通过读-写共享变量(堆内存中的实例域,静态域和数组元素)来完成隐式通信。JMM 控制 Java 线程之间的通信,决定一个线程对共享变量的写入何时对另一个线程可见,
Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量(线程共享的变量)存储到内存和从内存中取出变量这样底层细节。
Java 内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。
主内存、工作内存详解:
计算机在高速的 CPU 和相对低速的存储设备之间使用高速缓存,作为内存和处理器之间的缓冲。将运算需要使用到的数据复制到缓存中,让运算能快速运行,当运算结束后再从缓存同步回内存之中。
在多处理器的系统中(或者单处理器多核的系统),每个处理器内核都有自己的高速缓存,它们有共享同一主内存(Main Memory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。
为此,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,来维护缓存的一致性。
指令重排:
执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
1.编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2.指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3.内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
指令重排需要满足以下两个条件:
在单线程环境下不能改变程序运行的结果;
存在数据依赖关系的不允许重排序
happens-before具体的一共有八项规则:
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
- start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
- join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
- 程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
- 对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。
可参考:【并发重要原则】happens-before理解和应用 - 简书
八、多线程中 synchronized 锁升级的原理是什么?
synchronized 锁升级原理:在锁对象的对象头里面有一个 threadid 字段,在
第一次访问的时候 threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置
为其线程 id,再次进入的时候会先判断 threadid 是否与其线程 id 一致,如果
一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋
循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对
象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁
的升级。
锁的升级的目的:锁升级是为了减低了锁带来的性能消耗。在 Java 6 之后优化
synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的
方式,从而减低了锁带来的性能消耗。
补充对象头结构:
对象的几个部分的作用:
1.对象头中的Mark Word(标记字)主要用来表示对象的线程锁状态,另外还可以用来配合GC、存放该对象的hashCode;
2.Klass Word是一个指向方法区中Class信息的指针,意味着该对象可随时知道自己是哪个Class的实例;
3.数组长度也是占用64位(8字节)的空间,这是可选的,只有当本对象是一个数组对象时才会有这个部分;
4.对象体是用于保存对象属性和值的主体部分,占用内存空间取决于对象的属性数量和类型;
5.对齐字是为了减少堆内存的碎片空间。
Mark Word介绍:
biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。lock和biased_lock共同表示对象处于什么锁状态。
age:4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。
identity_hashcode:31位的对象标识hashCode,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象加锁后(偏向、轻量级、重量级),MarkWord的字节没有足够的空间保存hashCode,因此该值会移动到管程Monitor中。
thread:持有偏向锁的线程ID。
epoch:偏向锁的时间戳。
ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。
ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。
九、synchronized、volatile、CAS 比较
(1)synchronized 是悲观锁,属于抢占式,会引起其他线程阻塞。
(2)volatile 提供多线程共享变量可见性和禁止指令重排序优化。
volatile 提供 happens-before 的保证,确保一个线程的修改能对其他线程是可
见的。当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主
存,当有其他线程需要读取时,它会去内存中读取新值。
从实践角度而言,volatile 的一个重要作用就是和 CAS 结合,保证了原子性
(3)CAS 是基于冲突检测的乐观锁(非阻塞)
十、final,什么是不可变对象,它对写并发应用有什么帮助?
不可变对象(Immutable Objects)即对象一旦被创建它的状态(对象的数据,也即对象属性值)就不能改变,反之即为可变对象(Mutable Objects)。
不可变对象的类即为不可变类(Immutable Class)。Java 平台类库中包含许多不
可变类,如 String、基本类型的包装类、BigInteger 和 BigDecimal 等。
只有满足如下状态,一个对象才是不可变的;
1、它的状态不能在创建后再被修改;
2、所有域都是 final 类型;
3、它被正确创建(创建期间没有发生 this 引用的逸出)。
不可变对象保证了对象的内存可见性,对不可变对象的读取不需要进行额外的同步手段,提升了代码执行效率。
十一、AQS(Abstract Queued Synchronizer)
1、AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器。
2、AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。
3、AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。
private volatile int state;//共享变量,使用volatile修饰保证线程可见性
状态信息通过protected类型的getState,setState,compareAndSetState进行操作。
4、AQS底层使用了模板方法模式
同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样
(模板方法模式很经典的一个应用):
1. 使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放)
2. 将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。
AQS使用了模板方法模式,自定义同步器时需要重写下面几个AQS提供的模板方法:
以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,
会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就
会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会
获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累
加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能
保证state是能回到零态的。
再以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为
N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行
完后countDown()一次,state会CAS(Compare and Swap)减1。等到所有子
线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从
await()函数返回,继续后余动作。
一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现
tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS
也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。
十二、ThreadLocal造成内存泄漏的原因?
ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所
以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会
被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap 中就会出现key
为null的Entry。假如我们不做任何措施的话,value 永远无法被GC 回收,这个
时候就可能会产生内存泄露。ThreadLocalMap实现中已经考虑了这种情况,在
调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完
ThreadLocal方法后 最好手动调用remove()方法
十三、并发待补充
1、常用并发工具类
Semaphore,CountDownLatch,CyclicBarrier;