一 进程和线程的区别
windows是多任务的,一般一个一个任务是一个线程
1. 进程是资源分配的最小单位,线程是程序执行的最小单位
2 .进程有自己的独立地址空间,每启动一个进程,系统就会为它分配地址空间,建立数据表来维护代码段、堆栈段
和数据段,这种操作非常昂贵。而线程是共享进程中的数据的,使用相同的地址空间,因此CPU切换一个线程的
花费远比进程要小很多,同时创建一个线程的开销也比进程要小很多。
3. 线程之间的通信更方便,同一进程下的线程共享全局变量、静态变量等数据,而进程之间的通信需要以通信的方
式(IPC)进行。不过如何处理好同步与互斥是编写多线程程序的难点。
4. 但是多进程程序更健壮,多线程程序只要有一个线程死掉,整个进程也死掉了,而一个进程死掉并不会对另外一
个进程造成影响,因为进程有自己独立的地址空间。
5. 一个进程至少有一个线程,如果只有一个线程就是单线程的,否则为多线程。
二 Lock和Sychronized
1. 一些需要了解的基础知识
(1)volatile
下面只摘录了一些重要内容,原版文章在:
https://www.cnblogs.com/dolphin0520/p/3920373.html
(a)内存模型的相关概念
大家都知道,计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及
到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在
一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度
比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行
的速度。因此在CPU里面就有了高速缓存。
也就是,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么
CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓
存中的数据刷新到主存当中。举个简单的例子,比如下面的这段代码:
i = i + 1;
当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后CPU执行
指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。
这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核CPU中,
每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会
出现这种问题,只不过是以线程调度的形式来分别执行的)。本文我们以多核CPU为例。
比如同时有2个线程执行这段代码,假如初始时i的值为0,那么我们希望两个线程执行完之后i的值变为
2。但是事实会是这样吗?
可能存在下面一种情况:初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,
然后线程1进行加1操作,然后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值还是0,进行加
1操作之后,i的值为1,然后线程2把i的值写入内存。
最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为
共享变量。
也就是说,如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能
存在缓存不一致的问题。
为了解决缓存不一致性问题,通常来说有以下2种解决方法:
-- 通过在总线加LOCK#锁的方式
-- 通过缓存一致性协议
这2种方式都是硬件层面上提供的方式。
在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他
部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件
访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中 如果一个线程在执行
i = i +1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执
行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致
的问题。
但是上面的方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下
所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的
共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其
他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他
CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
(b)Java内存模型
在Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽各个硬件平
台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。那么Java
内存模型规定了哪些东西呢,它定义了程序中变量的访问规则,往大一点说是定义了程序执行的次序。注
意,为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提
升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在java内存模型中,也会存在缓存一
致性问题和指令重排序的问题。
Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的
工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存
进行操作。并且每个线程不能访问其他线程的工作内存。
(a)原子性
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,
要么执行,要么不执行。
上面一句话虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子:
请分析以下哪些操作是原子性操作:
x = 10; //语句1 y = x; //语句2 x++; //语句3 x = x + 1; //语句4
只有语句1是原子性操作,其他三个语句都不是原子性操作。语句1是直接将数值10赋值给x,也就
是说线程执行这个语句的会直接将数值10写入到工作内存中。语句2实际上包含2个操作,它先要去读取
x的值,再将x的值写入工作内存,虽然读取x的值以及将x的值写入工作内存这2个操作都是原子性操作,
但是合起来就不是原子性操作了。
同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。所以上面4个语
句只有语句1的操作具备原子性。也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,
变量之间的相互赋值不是原子操作)才是原子操作
不过这里有一点需要注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,
不能保证其原子性。但是好像在最新的JDK中,JVM已经保证对64位数据的读取和赋值也是原子性操作了。
从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的
原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个
线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
(b)可见性
对于可见性,Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改
的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可
见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可
能还是原来的旧值,因此无法保证可见性。
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程
获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
(c)有序性
volatile禁止指令重排,指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结
果的前提下,尽可能地提高并行度,索引允许指令重排,指令重拍不影响单线程执行的正确性,但是会影响
多线程并发执行的正确性。
行,却会影响到多线程并发执行的正确性。
在Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外可以通过
synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,
相当于是让线程顺序执行同步代码,自然就保证了有序性。
(c)现在在来谈volatile
volatile能保证线程的可见性、有序性但是不能保证原子性。
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
(i)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说
是立即可见的。
(ii)禁止进行指令重排序。
(2)CAS
(a)为什么要有CAS
synchronized是乐观锁;CAS是乐观锁。
Java的线程安全性问题主要关注点有3个:可见性、有序性和原子性。Java内存模型(JMM)解决了可见性
和有序性的问题(volatile),而锁解决了原子性的问题(synchronized和lock)。
悲观锁
这种通过使用一致的锁定协议来协调对共享状态的访问,可以确保无论哪个线程持有共享变量的锁,都采用
独占的方式来访问这些变量。独占锁其实就是一种悲观锁
悲观锁的问题:
-- 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
-- 一个线程持有锁会导致其它所有需要此锁的线程挂起。
-- 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优
-- 先级倒置,引起性能风险。
于是这个时候有了乐观锁
乐观锁
乐观锁( Optimistic Locking)其实是一种思想。相对悲观锁而言,乐观锁假设认为数据一般情况下不会造
成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用
户错误的信息,让用户决定如何去做。乐观锁的具体实现细节就两个部分冲突检测和数据更新。
(2)CAS介绍
(a)CAS算法介绍
CAS是一种无锁算法,CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和
内存值V相同时,将内存值V修改为B,否则什么都不做。
上面的说法比较难懂,下面举例说明:
如图所示,假设t1和t2线程都同时去更改同一值为56的变量,所以他们会把主内存的值完全拷贝一份到自
己的工作内存空间,所以t1和t2线程的预期值都为56。假设t1在与t2线程竞争中线程t1能去更新变量的值,而
其他线程都失败。(失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次发起尝试)。t1线程
去更新变量值改为57,然后写到内存中。此时对于t2来说,内存值变为了57,与预期值56不一致,就操作失败
了。(以上文字摘自百度百科和https://blog.csdn.net/moakun/article/details/80144900)
(b)CAS有个经典的ABA问题
具体是啥,此处略,闲的蛋疼的时候在去了解 解决方案:在CAS操作时,带上版本号,没修改一次,
版本号+1,不但比较对象是否相等,还要比较版本号是否一致。
(3)AQS
(a)简介
需要掌握:
* 知道AQS 是volatile int state+CHL队列
* CLH是一个双向队列
AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的
工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需
要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现
的,即将暂时获取不到锁的线程加入到队列中。CLH (Craig,Landin,and Hagersten)队
列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联
关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)
原理图如下:
AQS,它维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线
程争用资源被阻塞时会进入此队列)。这里volatile是核心关键词,具体volatile的语义,
在此不述。state的访问方式有三种:
getState()
setState()
compareAndSetState()
AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如
ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。
(b)AQS方法介绍
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享
资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/
唤醒出队等),AQS已经在顶层实现好了。
自定义同步器实现时主要实现以下几种方法:
* isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
* tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
* tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
* tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有
剩余可用资源;正数表示成功,且有剩余资源。
* tryReleaseShared(int):共享方式。尝试释放资源,成功则返回true,失败则返回false。
(4)可重入锁和不可重入锁
synchronized 和 ReentrantLock 都是可重入锁
(a)不可重入锁
所谓不可重入锁,即若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次
获取锁时,就会获取不到被阻塞。我们尝试设计一个不可重入锁:
public class Lock{ private boolean isLocked = false; public synchronized void lock() throws InterruptedException{ while(isLocked){ wait(); } isLocked = true; } public synchronized void unlock(){ isLocked = false; notify(); } }
使用该锁:
public class Count{ Lock lock = new Lock(); public void print(){ lock.lock(); doAdd(); lock.unlock(); } public void doAdd(){ lock.lock(); //do something lock.unlock(); } }
当前线程执行print()方法首先获取lock,接下来执行doAdd()方法就无法执行doAdd()中的
逻辑,必须先释放锁。这时候产生了死锁。
(b)可重入锁
指的是以线程为单位,当一个线程获取对象锁之后,这个线程可以再次获取本对象上的
锁,而其他的线程是不可以的。我们设计一种可重入锁:
public class Lock{ boolean isLocked = false; Thread lockedBy = null; int lockedCount = 0; public synchronized void lock() throws InterruptedException{ Thread thread = Thread.currentThread(); while(isLocked && lockedBy != thread){ wait(); } isLocked = true; lockedCount++; lockedBy = thread; } public synchronized void unlock(){ if(Thread.currentThread() == this.lockedBy){ lockedCount--; if(lockedCount == 0){ isLocked = false; notify(); } } } }
我们设计两个线程调用print()方法,第一个线程调用print()方法获取锁,进入lock()方法,
由于初始lockedBy是null,所以不会进入while而挂起当前线程,而是是增量
lockedCount并记录lockBy为第一个线程。接着第一个线程进入doAdd()方法,由于同一
线程,所以不会进入while而挂起,接着增量lockedCount,当第二个线程尝试lock,由
于isLocked=true,所以他会获取该锁,直到第一个线程调用两次unlock()将lockCount递
减为0,才将标记为isLocked设置为false。
2.sychronized()修饰静态方法和普通方法的区别?
synchronized修饰不加static的方法,锁是加在单个对象上,不同的对象没有竞争关系;修饰加了static的
方法,锁是加载类上,这个类所有的对象竞争一把锁。
3.Lock和Sychronized()
(1)Lock和Sychronized的区别
简单的说区别是三点:等待可中断、可实现公平锁、锁可以绑定多个条件。
(a)等待可中断
sychronized,是java内置的关键字,如果线程A获取了锁,线程B只能无限的等待下去,自动释
放线程锁;Lock,是一个接口,如果线程A获取了锁,线程B可以选择放弃等待,需要手动调用
unlock();方法释放线程锁。
(b)可实现公平锁
sychronized锁是公平的;Lock的锁可以是公平的也可以是非公平的(ReentrantLock默认情况
下是非公平的)
(c)可以绑定多个条件
面试时候尽量不要答。
(2)Lock
(a)接口方法介绍
其接口方法如下:
public interface Lock { void lock(); void lockInterruptibly() throws InterruptedException; boolean tryLock(); boolean tryLock(long time, TimeUnit unit) throws InterruptedException; void unlock(); Condition newCondition(); }
(i)lock():用来获取锁。如果锁已被其他线程获取,则进行等待,需要手动调用unlock()方法释
放锁。示例如下:
Lock lock = ...; lock.lock(); try{ //处理任务 }catch(Exception ex){ }finally{ lock.unlock(); //释放锁 }
(ii)tryLock():尝试获取锁,如果获取成功,则返回true,如果获取失
败(即锁已被其他线程获取),则返回false。示例代码
如下:
Lock lock = ...; if(lock.tryLock()) { try{ //处理任务 }catch(Exception ex){ }finally{ lock.unlock(); //释放锁 } }else { //如果不能获取锁,则直接做其他事情 }
(iii)tryLock(long time, TimeUnit unit)
该方法和tryLock()方法是类似的,区别在于这个方法在拿不到锁时会等待一定时间,如
果在时间期限之内拿不到锁,就返回false。如果一开始拿到锁或在等待期间内拿到了锁,
则返回true。
(iv)lockInterruptibly()
如果获取了锁定立即返回,如果没有获取锁定,当前线程处于休眠状态,直到或者锁定,
或者当前线程被别的线程中断;由于lockInterruptibly()的声明中抛出了异常,所以
lock.lockInterruptibly()必须放在try块中或者在调用lockInterruptibly()的方法外声明抛出
InterruptedException。
示例代码如下:
public void method() throws InterruptedException { lock.lockInterruptibly(); try { //..... } finally { lock.unlock(); } }
注意:当一个线程获取了锁之后,是不会被interrupt()方法中断的。因为interrupt()方法不能中断正
在运行过程中的线程,只能中断阻塞过程中的线程。因此当通过lockInterruptibly()方法获取
某个锁时,如果不能获取到,只有进行等待的情况下,是可以响应中断的。而用
synchronized修饰的话,当一个线程处于等待某个锁的状态,是无法被中断的,只有一直等
待下去。
(b)ReenTrantLock
需要掌握:
ReenTrantLock的实现原理
(i)特性
ReenTrantLock是Lock接口的实现类,且是唯一的实现类。
* ReenTrantLock可以指定是公平锁还是非公平锁,默认是非公平锁。而
synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。
公平锁:线程获取锁的顺序和调用lock的顺序一样,FIFO;
非公平锁:线程获取锁的顺序和调用lock的顺序无关,全凭运气。
* ReenTrantLock提供了一个Condition(条件)类,用来实现分组唤醒需要
唤醒的线们,而不是像synchronized要么随机唤醒一个线程要么唤醒全部
线程
* ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过
lock.lockInterruptibly()来实现这个机制。
(ii)实现原理,参考
https://www.cnblogs.com/xrq730/p/4979021.html
简单的说,比如有线程1和线程2,线程1用CAS方法获取state,假设成功,
那么state由0改为1;这时,线程2在用CAS方法获取state,这时线程2被阻
塞。lock()方法执行的是如下逻辑:ReentrantLock根据传入构造方法的布尔
型参数实例化出Sync的实现类FairSync和NonfairSync,分别表示公平的
Sync和非公平的Sync。由于ReentrantLock我们用的比较多的是非公平锁,
所以看下非公平锁是如何实现的。假设线程1调用了ReentrantLock的lock()
方法,那么线程1将会独占锁,整个调用链十分简单:
其实lock()方法就是两步骤:
* 设置AbstractQueuedSynchronizer的state为1
* 设置AbstractOwnableSynchronizer的thread为当前线程
ReentrantLock的基本实现可以概括为:
先通过CAS尝试获取锁。如果此时已经有线程占据了锁,那就加入CLH队列
并且被挂起。当锁被释放之后,排在CLH队列队首的线程会被唤醒,然后
CAS再次尝试获取锁。在这个时候,如果:
非公平锁:如果同时还有另一个线程进来尝试获取,那么有可能会让这
个线程抢先获取。
公平锁:如果同时还有另一个线程进来尝试获取,当它发现自己不是在
队首的话,就会排到队尾,由队首的线程获取到锁。
会先调用上面的方法。如果状态为0,则表明此时无人占有锁。此时尝试进行
set,一旦成功,则成功占有锁。如果状态不为0,再判断是否是当前线程获取
到锁。如果是的话,将状态+1,因为此时就是当前线程,所以不用CAS。这
也就是可重入锁的实现原理。
(3)sychronized
需要掌握:
* JDK1.6对Sychronized做的优化
* 轻量级锁的实现细节
* 轻量级锁如何膨胀为重量级锁?可逆吗?
线程切换本身是比较消耗资源的,它需要操作系统帮忙,所以就需要重用户态
转换到内核态,状态的转换会消耗很多资源。JDK1.6对sychronized的优化思路就
是使其不进行状态转化,使用锁技术实现线程的同步。
(a)原理
(i)修饰代码块
同步语句块的实现使用的是monitorenter 和 monitorexit 指令,其中
monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步
代码块的结束位置。这两个指令都需要一个reference类型的参数来指明要
锁定和解锁的对象,如果Java程序中的synchronized明确指定了对象参数,
那就是这个对象的reference;如果没有明确指定,那就根据sychronized
修饰的是实例方法还是类方法,去取对应的对象实例或是Class对象来作为
锁对象。根据虚拟机规范的要求,在执行monitorenter指令时,首先要尝试
获取对象的锁,如果这个对象没有被锁定或者当前线程已经拥有了那个对
象的锁,把锁的计数器加1。相应的,在执行monitorexit指令时,会将锁计
数器减1,当计数器为0的时,锁就被释放。如果获取对象锁失败,那当前
线程就要阻塞等待,直到对象锁被另外一个线程释放为止。
(ii)修饰方法
同步方法是通过方法常量池中的方法表关键字来控制, 如果用
synchronized修改方法,那么常量池里面会有ACC_SYNCHRONIZEDR
进行修饰。当线程访问加了synchronized的方法时, 会去判断是否有
ACC_SYNCHRONIZED标识。
* 如果有该标识, 当前线程会尝试获取该对象的monitor所有权获取所有
权的依据是monitor的计数器值是否为0, 为0则获取到了,同时将计数
器值 +1
* 如果已获取到monitor所有权, 则直接将计数器 +1并进入运行指令阶段
* 如果没有尝试获取该对象的monitor持有权,会进入等待队列进行阻塞
(b)Jdk1.6对sychronized做的优化
(i)自旋锁
* 为了理解自旋锁,那么先了解非自旋锁的弊端:
非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获
取到锁的时候需要从内核态恢复,需要线程上下文切换。
* 什么是自旋锁?
是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该
线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到
锁才会退出循环。与传统的互斥锁相比,自旋锁中申请锁的线程一直处
于激活状态,而互斥锁申请锁的资源处于阻塞状态。自旋默认时10次,
JDK1.42中就已引入,需要手动开启,JDK1.6中默认开启。
* 自旋锁的优点:
自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是
active的;不会使线程进入阻塞状态,减少了不必要的上下文切换(
这里所谓的上下文切换即线程切换),执行速度快。
* 自旋锁的缺点
如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入
循环等待,白白消耗CPU;
自旋锁不是公平的,不公平的锁就会存在“线程饥饿”问题。
(ii)自适应自旋锁
该锁是为了解决自旋锁的缺点“如果某个线程持有锁的时间过长,就会导
致其它等待获取锁的线程进入循环等待,白白消耗CPU”而产生的。
自适应一位着自旋的时间不在固定了,而是由前一次在同一个锁上的自旋
时间及锁的拥有者的状态来决定。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程
正在运行中,那么虚拟机会认为这次自旋也很有可能再次成功,进而它将允许
自旋等待持续相对更长的时间,比如100个循环。另外对于某个锁,自旋很少
成功获得过,那在以后要获取这个锁时,将肯能省略掉自旋过程,以避免浪费
处理器的资源。
(iii)锁消除
锁消除时指虚拟机即时编译器运行时,对一些代码上要求同步,但是被
检测到不可能存在共享数据竞争的锁进行消除。
锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码
中,堆上的所有数据都不会逃逸出去重而被其它线程访问到,那就可以把它们
当做栈上的数据对待,认为它们是线程私有的,同步加锁就无需进行。
变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是程序员
自己应该是很清楚的,怎么会在明知道不存在数据争用的情况下要求同步呢?
答案是有许多同步措施并不是程序员自己加入的,同步的代码在Java程序中
的普遍程度也许超过了大部分读者的想象。我们来看看代码清单13-6中的例
子,这段非常简单的代码仅仅是输出3个字符串相加的结果,无论是源码字面
上还是程序语义上都没有同步。
代码清单13-6 一段看起来没有同步的代码:
public String concatString(String s1,String s2,String s3){ return s1+s2+s3; }
我们也知道,由于String是一个不可变的类,对字符串的连接操作总是通过生
成新的String对象来进行的,因此Javac编译器会对String连接做自动优化。
在JDK 1.5之前,会转化为StringBuffer对象的连续append()操作,在
JDK 1.5及以后的版本中,会转化为StringBuilder对象的连续append()操作,
即代码清单13-6中的代码可能会变成代码清单13-7的样子。
代码清单13-7 Javac转化后的字符串连接操作:
public String concatString(String s1,String s2,String s3){ StringBuffer sb=new StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); return sb.toString(); }
现在大家还认为这段代码没有涉及同步吗?每个StringBuffer.append()方
法中都有一个同步块,锁就是sb对象。虚拟机观察变量sb,很快就会发现
它的动态作用域被限制在concatString()方法内部。也就是说,sb的所有
引用永远不会“逃逸”到concatString()方法之外,其他线程无法访问到它,
因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代
码就会忽略掉所有的同步而直接执行了。客观地说,既然谈到锁消除与逃逸
分析,那虚拟机就不可能是JDK 1.5之前的版本,实际上会转化为非线程安
全的StringBuilder来完成字符串拼接,并不会加锁,但这也不影响笔者用这
个例子证明Java对象中同步的普遍性。
(iv)锁粗化
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制的尽
可能的小,这样是为了使得需要同步的操作数量尽可能的变小,如果存在
竞争,那等待锁的线程也能尽快拿到锁。大部分情况下,上面的原则是正
确的,但是如果一系列的连续操作都对同一对象反复的加锁和解锁,那即
使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的新能损耗。
例如下面代码中的连续append()就属于这种情况:
public String concatString(String s1,String s2,String s3) { StringBuffer sb=new StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); }
如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加
锁同步的范围扩展(粗化)到整个操作序列的外部,按上例,会扩展到第
一个append()操作之前直至最后一个append()操作之后,这样只需要加锁
一次就可以了。
(v)轻量级锁
概念
相对于重量级锁而言的。重量级锁,就是使用操作系统互斥量来实现
的锁。轻量级锁并不是用来代替重量级锁的,它的目标是减少无实际竞争
情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用
户态切换、线程阻塞造成的线程切换等。在有两个以上的线程争用同一把
锁时,轻量级锁不在有效,它将膨胀为重量级锁。
缺点
如果锁竞争激烈,那么轻量级将很快膨胀为重量级锁,那么维持轻量
级锁的过程就成了浪费。
实现原理
轻量级锁的实现依靠对象头的Mark Word。那么现在先了解一下HotSpot
虚拟机的内存布局。
HotSpot虚拟机的对象头(Object Header)分为两部分信息,第一部
分用于存储对象自身运行时的数据,如哈希码(HashCode)、GC分代年
龄(Generation GC Age)等,这部分数据的长度在32位和64位虚拟机中
分别位32bit和64bit,官方称它为“Mark Word”,它是实现轻量级锁和偏向
锁的关键;另外一部分用于存储指向方法区对象类型的数据指针,如果是
数组对象的话,如果是数组对象的话,还有一个额外的部分用于存储数组
的长度。
HotSpot虚拟机对象头的Mark Word如下:
各种锁对应的Mark Word存储内容如下:
简单介绍了对象的内存布局之后,我们把话题转到轻量级锁的执行过程
上,在代码进入同步块的时候,如果此同步对象没有被锁定(锁标识位为01
状态),虚拟机会在当前线程的栈帧中建立一个名为“锁记录(Lock Record
)”的空间,用于存储锁对象的Mark Word的拷贝(官方把这一拷贝加了
Displaced前缀,即Displaced Mark Word),这时候线程堆中与对象头的状态
如下图所示:
然后虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record
的指向,如果这个更新成功了,那么这个线程就拥有了对象的锁,并且对象
的Mark Word的锁标志位(Mark Word的最后两位)将变为 “00”,即表示此对
象处于轻量级锁定状态这时候线程堆栈与对象头的状态如下图所示:
如果这个更新操作失败了,虚拟机首先会检查对对象的Mark Word是否指向
当前线程的栈帧,如果是说明当前线程已经拥有了这个对象的锁,那就可以
直接进入同步块继续执行。否则说明这个锁的对象已经被其它线程抢占了。
如果有两条以上的线程争用同一个锁,那么轻量级锁不在有效,要膨胀为重
量级锁。
关于轻量级锁是如何膨胀为重量级锁的:
现在来假设一个场景:当获取到锁的线程执行同步体之内的代码的时
候,另一个线程也完成了上面创建锁记录空间,将对象头中的MarkWord
复制到自己的锁记录中,尝试用CAS将对象头中的MarkWord修改为指向
自己的锁记录的指针,但是由于之前获取到锁的线程已经将MarkWord中
的记录修改过了(并且现在还在执行同步体中的代码),与这个现在试图
将MarkWord替换为自己的锁记录的线程自己的锁记录中的MarkWord的值
不符,CAS操作失败,因此这个线程就会不停地循环使用CAS操作试图将
MarkWord替换为自己的记录。这个循环是有次数限制的,如果在循环结
束之前CAS操作成功,那么该线程就可以成功获取到锁,如果循环结束之
后依然获取不到锁,则锁获取失败,MarkWord中的记录会被修改为指向
重量级锁的指针,然后这个获取锁失败的线程就会被挂起,阻塞了。当持
有锁的那个线程执行完同步体之后想用CAS操作将MarkWord中的记录改
回它自己的栈中最开始复制的记录的时候会发现MarkWord已被修改为指
向重量级锁的指针,因此CAS操作失败,该线程会释放锁并唤起阻塞等待
的线程,开始新一轮夺锁之争,而此时,轻量级锁已经膨胀为重量级锁,
所有竞争失败的线程都会阻塞,而不是自旋。轻量级锁一旦膨胀为重量级
锁,则不可逆转。因为轻量级锁状态下,自旋是会消耗cpu的,但是锁一
旦膨胀,说明竞争激烈,大量线程都做无谓的自旋对cpu是一个极大的浪
费。
解锁:
轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替
换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前
锁存在竞争,锁就会膨胀成重量级锁。
(vi)偏向锁
概念
偏向锁的“偏”就是偏心、偏袒的“偏”,它的意思是这个锁会偏向于第一个获取
它的线程,如果在接下来的过程中,该锁没有被其它的线程获取,则持有锁
的线程则永远不需要在执行同步。
为什么要使用偏向锁
如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维
护轻量级锁都是浪费的。偏向锁的目标是,减少无竞争且只有一个线程使用
锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都
至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。
实现原理
在Mark Word中CAS记录owner。
当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为
“01”,即偏向模式,同时使用CAS操作把获取到这个锁的线程ID记录在对象
的Mark Word中,如果CAS操作成功,持有偏向锁的线程以后进入这个锁相
关的同步块时,虚拟机都可以不在进行任何同步操作。
偏向锁的升级
何时升级?
当一个线程获取了偏向锁,当另外一个线程去尝试获取这个锁时,偏向模式
就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向后恢复到未
锁定或轻量级锁定的状态。
下面举例说明偏向锁升级的过程:、
* 假设线程1当前拥有偏向锁对象,线程2是需要竞争到偏向锁
* 线程2来竞争锁对象;
* 判断当前对象头是否是偏向锁;
* 判断拥有偏向锁的线程1是否还存在;
* 线程1不存在,直接设置偏向锁标识为0(线程1执行完毕后,不会主动去释放偏
向锁);
* 使用CAS替换偏向锁线程ID为线程2,锁不升级,仍为偏向锁;
* 线程1仍然存在,暂停线程1;
* 设置锁标志位为00(变为轻量级锁),偏向锁为0;
* 从线程1的空闲monitor record中读取一条,放至线程1的当前
monitor record中;
* 更新mark word,将mark word指向线程1中monitor record的指针;
* 继续执行线程1的代码;
* 锁升级为轻量级锁;
* 线程2自旋来获取锁对象;
关于轻量级锁和偏向锁详细请看:
https://blog.csdn.net/truong/article/details/74942155
5.可重入锁和不可重入锁
synchronized 和 ReentrantLock 都是可重入锁。
(1)不可重入锁
所谓不可重入锁,即若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再
次获取锁时,就会获取不到被阻塞。我们尝试设计一个不可重入锁:
public class Lock{ private boolean isLocked = false; public synchronized void lock() throws InterruptedException{ while(isLocked){ wait(); } isLocked = true; } public synchronized void unlock(){ isLocked = false; notify(); } }
使用该锁:
public class Lock{ boolean isLocked = false; Thread lockedBy = null; int lockedCount = 0; public synchronized void lock() throws InterruptedException{ Thread thread = Thread.currentThread(); while(isLocked && lockedBy != thread){ wait(); } isLocked = true; lockedCount++; lockedBy = thread; } public synchronized void unlock(){ if(Thread.currentThread() == this.lockedBy){ lockedCount--; if(lockedCount == 0){ isLocked = false; notify(); } } } }
我们设计两个线程调用print()方法,第一个线程调用print()方法获取锁,进入lock()方
法,由于初始lockedBy是null,所以不会进入while而挂起当前线程,而是是增量
lockedCount并记录lockBy为第一个线程。接着第一个线程进入doAdd()方法,由于同
一线程,所以不会进入while而挂起,接着增量lockedCount,当第二个线程尝试lock,
由于isLocked=true,所以他不会获取该锁,直到第一个线程调用两次unlock()将
lockCount递减为0,才将标记为isLocked设置为false。
三.线程中断
1.thread.interrupt()概述
interrupt() 方法只是改变中断状态而已,它不会中断一个正在运行的线程。这一方法实际
完成的是,给受阻塞的线程发出一个中断信号,这样受阻线程就得以退出阻塞的状态。
更确切的说,如果线程被Object.wait, Thread.join和Thread.sleep三种方法之一阻塞,此时
调用该线程的interrupt()方法,那么该线程将抛出一个 InterruptedException中断异常(该
线程必须事先预备好处理此异常),从而提早地终结被阻塞状态。如果线程没有被阻塞,
这时调用interrupt()将不起作用,直到执行到wait(),sleep(),join()时,才马上会抛出
InterruptedException。
2.各种情况interrupt()
(1)sleep() &interrupt()
线程A正在使用sleep()暂停着: Thread.sleep(100000),如果要取消它的等待状态,
可以在正在执行的线程里(比如这里是B)调用a.interrupt()[a是线程A对应到的
Thread实例],令线程A放弃睡眠操作。即,在线程B中执行a.interrupt(),处于阻
塞中的线程a将放弃睡眠操作。当在sleep中时线程被调用interrupt()时,就马上会
放弃暂停的状态并抛出InterruptedException。抛出异常的,是A线程。
(2)wait() &interrupt()
程A调用了wait()进入了等待状态,也可以用interrupt()取消。不过这时候要注意锁
定的问题。线程在进入等待区,会把锁定解除,当对等待中的线程调用interrupt()时,
会先重新获取锁定,再抛出异常。在获取锁定之前,是无法抛出异常的。
(3)join() &interrupt()
当线程以join()等待其他线程结束时,当它被调用interrupt(),它与sleep()时一样,
会马上跳到catch块里.。
四.run()、start()、wait()、join()
1.run()和start()区别:
(1)start():真正实现了多线程运行,这时无需等待run方法体代码执行完毕而直接继
续执行下面的代码。
(2)run():作为普通方法的方式调用,程序还是要顺序执行,并不能多线程执行还是
要等待run方法体执行完。
2.sleep和wait区别
(1)sleep(): 在调用sleep()方法的过程中,线程不会释放对象锁,就像当前线程仍
然在执行一样。
补充,关于sleep用法的说明:
class ThreadA extends Thread { public void run(){ System.out.println("ThreadA is running"); } } public class TestNew { public static void main(String[] args)throws InterruptedException { // TODO Auto-generated method stub ThreadA ta = new ThreadA(); ta.start(); ta.sleep(5000); System.out.println("TestNew is running"); } }
其实这段语句是主线程睡眠5秒,而不是ta线程,sleep
声明在哪个线程里哪个线程睡眠
(2)wait(): 当调用wait()方法的时候,线程会放弃对象锁,进入等待此对象的等待锁
定池,只有针对此对象 调用notify()方法后本线程才进入对象锁定池准备。
3.join()
thread.Join把指定的线程加入到当前线程,可以将两个交替执行的线程合并为顺序执行
的线程。比如在线程B中调用了线程A的Join()方法,直到线程A执行完毕后,才会继续
执行线程B。join方法必须在放在start()方法调用之后,否则不生效。
五.java间线程通信的方式
1.wait,notify,notifyAll
2.管道通信。java.io.PipedInputStream 和 java.io.PipedOutputStream进行通信
六.Callable和Future
使线程具有返回值的功能。Callable用于拿到结果,Future用于返回结果。
直接上例子:
eg1:非线程池的方式返回一个int类型的数据
public class CallableAndFuture { public static void main(String[] args) { Callable<Integer> callable = new Callable<Integer>() { public Integer call() throws Exception { return 10; } }; FutureTask<Integer> future = new FutureTask<Integer>(callable); new Thread(future).start(); try { Thread.sleep(5000);// 可能做一些事情 System.out.println(future.get()); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } } }
打印结果:10
eg2:非线程池的方式返回一个String类型的数据。
public class CallableAndFuture { public static void main(String[] args) { Callable callable = new Callable() { public String call() throws Exception { return "aaa"; } }; FutureTask<String> future = new FutureTask<String>(callable); new Thread(future).start(); try { Thread.sleep(5000);// 可能做一些事情 System.out.println(future.get()); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } } }
打印结果:aaa
eg3:线程池的方式
public class TaskCallable implements Callable<String>{ private int id; public TaskCallable(int id){ this.id = id; } public String call() throws Exception { return "result of taskWithResult "+id; } public static void main(String[] args) throws InterruptedException, ExecutionException { ExecutorService exec = Executors.newCachedThreadPool();//工头 ArrayList<Future<String>> results = new ArrayList<Future<String>>();// for(int i = 0 ; i < 10 ;i++){ // Future future=exec.submit(new TaskCallable(i)); // future.get(); results.add(exec.submit(new TaskCallable(i)));//submit返回一个Future,代表了即将要返回的结果 } for(int i=0;i<10;i++) { System.out.println(results.get(i).get()); } } }
打印结果:
result of taskWithResult 0
result of taskWithResult 1
result of taskWithResult 2
result of taskWithResult 3
result of taskWithResult 4
result of taskWithResult 5
result of taskWithResult 6
result of taskWithResult 7
result of taskWithResult 8
result of taskWithResult 9
七 线程池
1. 为什么要是使用线程池
因为创建和销毁线程的开销过大,所以使用线程池来解决这个问题。
2. 使用线程池的好处
(1)线程的重用
(2)控制线程池的并发数
(3)线程池可以对线程进行管理
2. java中的四种线程池
(1)newCachedThreadPool(这里遗留一个问题,线程池中的线程什么时候开始减少?)
cache线程池它的核心线程数为1,最大线程数是Integer的最大值,任务队列使用
SynchronousQueue,这样只要没有空闲的线程就会新建。线程池大小完全依赖于操
作系统(或者说JVM)能够创建的最大线程大小。
(2)newFixedThreadPool
固定大小的线程池。该程池创建时核心和最大线程数都设置成指定的线程数,缓冲队列使用的是无界队列。
(3)newScheduledThreadPool
创建一个定长线程池,支持定时及周期性任务执行。其设计思想是:每一个被调度的任务都会由线程池中一个线程去执
行,因此任务是并 发执行的,相互之间不会受到干扰。需要注意的是,只有当任务的执行时间到来时,ScheduedExecutor
才会真正启动一个线程,其余时间 ScheduledExecutor 都是在轮询任务的状态。看个例子:
package com.ibm.scheduler; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; public class ScheduledExecutorTest implements Runnable { private String jobName = ""; public ScheduledExecutorTest(String jobName) { super(); this.jobName = jobName; } @Override public void run() { System.out.println("execute " + jobName); } public static void main(String[] args) { ScheduledExecutorService service = Executors.newScheduledThreadPool(10); long initialDelay1 = 1; long period1 = 1; // 从现在开始1秒钟之后,每隔1秒钟执行一次job1 service.scheduleAtFixedRate( new ScheduledExecutorTest("job1"), initialDelay1, period1, TimeUnit.SECONDS); long initialDelay2 = 1; long delay2 = 1; // 从现在开始2秒钟之后,每隔2秒钟执行一次job2 service.scheduleWithFixedDelay( new ScheduledExecutorTest("job2"), initialDelay2, delay2, TimeUnit.SECONDS); } } Output: execute job1 execute job1 execute job2 execute job1 execute job1 execute job2
上例中展示了展示了 ScheduledExecutorService 中两种最常用的调度方法ScheduleAtFixedRate和ScheduleWithFixedDelay。
ScheduleAtFixedRate 每次执行时间为上一次任务开始起向后推一个时间间隔,即每次执行时间为 :initialDelay,
initialDelay+period, initialDelay+2*period, …;ScheduleWithFixedDelay 每次执行时间为上一次任务结束起向后推一个时间
间隔,即每次执行时间为:initialDelay,initialDelay+executeTime+delay, initialDelay+2*executeTime+2*delay。由此可见,
ScheduleAtFixedRate 是基于固定时间间隔进行任务调度,ScheduleWithFixedDelay 取决于每次任务执行的时间长短,是基
于不固定时间间隔进行任务调度。
(4)newSingleThreadExecutor
单线程的线程池就是线程数设置为1的固定线程池。
3.线程池参数
corePoolSize:线程池的基本大小,即在没有任务需要执行的时候线程池的大小,并且只有在工作队列满了
的情况下才会创建超出这个数量的线程。这里需要注意的是:在刚刚创建
ThreadPoolExecutor的时候,线程并不会立即启动,而是要等到有任务提交时才会启动,
除非调用了prestartCoreThread/prestartAllCoreThreads事先启动核心线程。再考虑到
keepAliveTime和allowCoreThreadTimeOut超时参数的影响,所以没有任务需要执行的时候,
线程池的大小不一定是corePoolSize。
maximumPoolSize:最大线程数。表明线程中最多能够创建的线程数量。
keepAliveTime:空闲的线程保留的时间。
TimeUnit:空闲线程的保留时间单位。
poolSize:线程池中当前线程的数量,当该值为0的时候,意味着没有任何线程,线程池会终止;同一时刻,
poolSize不会超过maximumPoolSize。这是一个我重未见过的参数,本来没打算记录,但是因为
在“线程池的执行流程”中涉及到该参数,所以暂时把它记录在这。
BlockingQueue:任务队列。
分为:直接提交的、有界的、无界的、优先任务队列。
(a)直接提交的任务队列(SynchronousQueue)
SynchronousQueue没有容量。提交的任务不会被真实的保存在队列中,而总是将新
任务提交给线程执行。如果没有空闲的线程,则尝试创建新的线程。如果线程数大于
最大值maximumPoolSize,则执行拒绝策略。
(b)有界的任务队列(ArrayBlockingQueue)
创建队列时,指定队列的最大容量。若有新的任务要执行,如果线程池中的线程数小
于corePoolSize,则会优先创建新的线程。若大于corePoolSize,则会将新任务加入
到等待队列中。若等待队列已满,此时判断线程池中总线程数是否小于
maximumPoolSize,如果总线程数不大于线程数最大值maximumPoolSize,则创建
新的线程执行任务。若大于maximumPoolSize,则执行拒绝策略。
(c)无界的任务队列(LinkedBlockingQueue)
与有界队列相比,除非系统资源耗尽,否则不存在任务入队失败的情况。若有新的任
务要执行,如果线程池中的线程数小于corePoolSize,线程池会创建新的线程。若大
于corePoolSize,此时又没有空闲的线程资源,则任务直接进入等待队列。当线程池
中的线程数达到corePoolSize后,线程池不会创建新的线程。若任务创建和处理的速
度差异很大,无界队列将保持快速增长,直到耗尽系统内存。使用无界队列将导致在
所有corePoolSize线程都忙时,新任务在队列中等待。这样,创建的线程就不会超过
corePoolSize(因此,maximumPoolSize 的值也就无效了)。当每个任务完全独立于
其他任务,即任务执行互不影响时,适合于使用无界队列;例如,在 Web 页服务器
中。这种排队可用于处理瞬态突发请求,当命令以超过队列所能处理的平均数连续到
达时,此策略允许无界线程具有增长的可能性。
(d)优先任务队列(PriorityBlockingQueue)
带有执行优先级的队列。是一个特殊的无界队列。ArrayBlockingQueue和
LinkedBlockingQueue都是按照先进先出算法来处理任务。而PriorityBlockingQueue
可根据任务自身的优先级顺序先后执行(总是确保高优先级的任务先执行)。
4.线程池的执行流程
(1)如果线程池的当前大小还没有达到基本大小(poolSize < corePoolSize),那么就新增加一个线程处理
新提交的任务。
(2)如果当前大小已经达到了基本大小,就将新提交的任务提交到阻塞队列排队,等候处理
workQueue.offer(command);
(3)如果队列容量已达上限,并且当前大小poolSize没有达到maximumPoolSize,那么就新增线程来处
理任务;
(4)如果队列已满,并且当前线程数目也已经达到上限,那么意味着线程池的处理能力已经达到了极限,
此时需要拒绝新增加的任务。至于如何拒绝处理新增的任务,取决于线程池的饱和策略
RejectedExecutionHandler。
5.线程池的拒绝策略
(1)直接丢弃(DiscardPolicy)
(2)丢弃队列中最老的任务(DiscardOldestPolicy)。
(3)默认,直接抛异常(AbortPolicy)
(4)将任务分给调用线程来执行(CallerRunsPolicy)。
6.线程池的面试题
(1)阿里的面试官问了个问题,如果corepollSize=10,MaxPollSize=20,如果来了25个线程怎么办?
答:慢慢的启动到10,然后把剩下的15个放到阻塞队列里面,并开始在线程池里面创建线程,直到
最大MaximumPoolSize。
八.多线程中的一些考点
1.ConcurrentHashMap
(1)ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配
一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。有些
方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需
要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。这里“按顺序”是很重要的,否则极有
可能出现死锁,在 ConcurrentHashMap内部,段数组是final的,并且其成员变量实际上也是final的,
但是,仅仅是将数组声明为final的并不保证数组成员也是final的,这需要实现上的保证。这可以确
保不会出现死锁,因为获得锁的顺序是固定的。
(2)ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁
ReentrantLock,在ConcurrentHashMap里扮演锁的角色,HashEntry则用于存储键值对数据。一个
ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链
表结构, 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素, 每个
Segment守护者一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获
得它对应的Segment锁。其结构如下图:
2.CountDownLatch
需要掌握:
CountDownLatch的实现原理
(1)作用:当一个线程需要等待其他线程完成各自的工作后再执行时,使用CountDownLatch。例如,应
用程序的主线程希望在负责启动框架服务的线程已经启动所有的框架服务之后再执行。
(2)实现原理
CountDownLatch是通过一个计数器来实现的,计数器的初始值为线程的数量。每当一个线程完成了
自己的任务后,计数器的值就会减1。当计数器值到达0时,表示所有的线程已经完成了任务,然后
等待的线程就可以恢复执行任务。
(3)主线程必须在启动其他线程后立即调用CountDownLatch.await()方法。这样主线程的操作就会在这
个方法上阻塞,直到其他线程完成各自的任务。其他N 个线程必须引用闭锁对象,因为他们需要通
知CountDownLatch对象,他们已经完成了各自的任务。这种通知机制是通过
CountDownLatch.countDown()方法来完成的;每调用一次这个方法,在造函数中初始化的count值
就减1。所以当N个线程都调 用了这个方法,count的值等于0,然后主线程就能通过await()方法,
恢复执行自己的任务。
3.ThreadLocal
(1)是线程的局部变量, 是每一个线程所单独持有的,其他线程不能对其进行访问,通常是类中的
private static 字段,其内存模型如下图:
从上面的结构图,我们已经窥见ThreadLocal的核心机制:
* 每个Thread线程内部都有一个Map。
* Map里面存储线程本地对象(key)和线程的变量副本(value)
综上,每个Thread对象内部都维护了一个ThreadLocalMap这样一个ThreadLocal的Map,可以存
放若干个ThreadLocal。当我们在调用get()方法的时候,先获取当前线程,然后获取到当前线程的
ThreadLocalMap对象,如果非空,那么取出ThreadLocal的value,否则进行初始化,初始化就是
将initialValue的值set到ThreadLocal中。
(2)ThreadLocal是以空间换取时间,效率比sychronized高
关于ThreadLocal在举一个例子:
class ConnectionManager { private static Connection connect = null; public static Connection openConnection() { if(connect == null){ connect = DriverManager.getConnection(); } return connect; } public static void closeConnection() { if(connect!=null) connect.close(); } }
上面的例子,在单线程中使用是没有任何问题的,但是在多线程中使用会有两个问题:第
一,这里面的2个方法都没有进行同步,很可能 在openConnection方法中会多次创建connect;
第二,由于connect是共享变量,那么必然在调用connect的地方需要使用到同步来保障线程
安全,因为很可能一个线程在使用connect进行数据库操作,而另外一个线程调用
closeConnection关闭链接。
所以出于线程安全的考虑,必须将这段代码的两个方法进行同步处理,并且在调用
connect的地方需要进行同步处理。这样将会大大影响程序执行效率,因为一个线程在使用
connect进行数据库操作的时候,其他线程只有等待。
首先分析一下,这地方到底需不需要将connect变量进行共享(即使用static)?事实上是
不需要的。假如每个线程中都有一个connect变量,各个线程之间对connect变量的访问实际上
是没有依赖关系的,即一个线程不需要关心其他线程是否对这个connect进行了修改的。那么
我们把代码改成如下:
class ConnectionManager { private Connection connect = null; public Connection openConnection() { if(connect == null){ connect = DriverManager.getConnection(); } return connect; } public void closeConnection() { if(connect!=null) connect.close(); } } class Dao{ public void insert() { ConnectionManager connectionManager = new ConnectionManager(); Connection connection = connectionManager.openConnection(); //使用connection进行操作 connectionManager.closeConnection(); }
这样处理由于每次都是在方法内部创建的连接,那么线程之间自然不存在线程安全问题,
但是这样会有一个致命的影响:导致服务器压力非常大,并且严重影响程序执行性能。这是
因为在方法中需要频繁地开启和关闭数据库连接,这样不尽严重影响程序执行效率,还可能
导致服务器压力巨大。那么在这种情况下我们就可以使用ThreadLocal。
(3)ThreadLocal原理
get()方法源码:
public T get() { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) { ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) return (T)e.value; } return setInitialValue();
set()方法源码:
public void set(T value) { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else createMap(t, value);
4.volatile
volatile能保证线程的可见性、有序性但是不能保证原子性。
下面只是笔记,详细版的原文在:
http://www.importnew.com/18126.html
(1)为了理解volatile首先引入并发编程的3个概念
(a)原子性:即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因打断,要
么就都不执行。举个最简单的例子,大家想一下假如为一个32位的变量赋值过
程不具备原子性的话,会发生什么后果?例如:i = 9; 假若一个线程执行到这个
语句时,我暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为
高16位赋值。那么就可能发生一种情况:当将低16位数值写入之后,突然被中
断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据。
(b)可见性:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其
他线程能够立即看得到修改的。举个简单的例子,看下面这段代码:
//线程1执行的代码 int i = 0; i = 10; //线程2执行的代码 j = i
(c)有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下 面这段代
码:
int i = 0; boolean flag = false; i = 1; //语句1 flag = true; //语句2
上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对
两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在
真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什
么呢?这里可能会发生指令重排序(Instruction Reorder)。
下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,
可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中
的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的 结果是一致的。
比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,
那么就有可能在执行过程中,语句2先执行而语句1后执行。 但是要注意,虽然
处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果
相同,那么它靠什么保证的呢?再看下面一个例子:
int a = 10; //语句1 int r = 2; //语句2 a = a + 3; //语句3 r = a*a; //语句4
这段代码有4个语句,那么是否可能按下面这个顺序执行顺序呢?
语句2 语句1 语句4 语句3
不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个
指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1
会在Instruction 2之前执行。虽然重排序不会影响单个线程内程序执行的结果,
但是多线程呢?下面看一个例子:
//线程1: context = loadContext(); //语句1 inited = true; //语句2 //线程2: while(!inited ){ sleep() } doSomethingwithconfig(context);
上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发
生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已
经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,
而此时context并没有被初始化,就会 导致程序出错。从上面可以看出,指令重排
序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。也就是说,
要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没
有被保证,就有可能会导致程序运行不正确。
(2)volatile解析
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了
两层语义:
(a)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值
对其他线程来说是立即可见的。
(b)禁止进行指令重排序。
禁止指令重排又包含两层意思:
(i)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经
进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
(ii)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把
volatile变量后面的语句放到其前面执行。
所以,volatile可以保证指令的可见性和有序性。
我们在回到前面的例子:
//线程1: context = loadContext(); //语句1 inited = true; //语句2 //线程2: while(!inited ){ sleep() } doSomethingwithconfig(context);
前面举这个例子的时候,提到有可能语句2会在语句1之前执行,那么就可能导致context还没被
初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。这里如果用volatile
关键字对inited变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证
context已经初始化完毕。
(3)volatile原理
下面这段话摘自《深入理解Java虚拟机》:
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字
时,会多出一个lock前缀指令”lock前缀指令实际上相当于一个内存屏障(也称内存栅栏),内存
屏障会提供3个功能:
(a)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排
到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
(b)它会强制将对缓存的修改操作立即写入主存;
(c)如果是写操作,它会导致其他CPU中对应的缓存行无效
(4)volatile使用场景
synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而
volatile关键字在某些情况下性能要优于synchronized,但是要注意 volatile关键字是无法替代
synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须
具备以下2个条件:
(a)对变量的写操作不依赖于当前值
(b)该变量没有包含在具有其他变量的不变式中
实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量
的当前状态。
例1,状态标记量:
volatile boolean flag = false; while(!flag){ doSomething(); } public void setFlag() { flag = true; }
例2,双重检锁的单例模式:
首先写一个错误的双重检索模式:
public class Singleton { private static Singleton uniqueSingleton; private Singleton() { } public Singleton getInstance() { if (null == uniqueSingleton) { synchronized (Singleton.class) { if (null == uniqueSingleton) { uniqueSingleton = new Singleton(); // error } } } return uniqueSingleton; } }
如果这样写,运行顺序就成了:
(i)检查变量是否被初始化(不去获得锁),如果已被初始化则立即返回。
(ii)获取锁。
(iii)再次检查变量是否已经被初始化,如果还没被初始化就初始化一个对象。
注:执行双重检查是因为,如果多个线程同时通过了第一次检查,并且其中一个线程首先通
过了第二次检查并实例化了对象,那么剩余通过了第一次检查的线程就不会再去实例化
对象。这样,除了初始化的时候会出现加锁的情况,后续的所有调用都会避免加锁而直
接返回,解决了性能消耗的问题。
隐患:
上述写法看似解决了问题,但是有个很大的隐患。实例化对象的那行代码(标记为error的那
行),实际上可以分解成以下三个步骤:
(i)分配内存空间
(ii)初始化对象
(iii)将对象指向刚分配的内存空间
但是有些编译器为了性能的原因,可能会将第二步和第三步进行重排序,
顺序就成了:
(i)分配内存空间
(ii)将对象指向刚分配的内存空间
(iii)初始化对象
现在考虑重排序后,两个线程发生了以下调用:
那么正确的双重检索模式如下:
public class Singleton { private volatile static Singleton uniqueSingleton; private Singleton() { } public Singleton getInstance() { if (null == uniqueSingleton) { synchronized (Singleton.class) { if (null == uniqueSingleton) { uniqueSingleton = new Singleton(); } } } return uniqueSingleton; } }
10.线程的状态
在JVM运行中,线程一共有New、Runnable、Block、Terminated(Dead)、
Waiting(Time_Waiting、Waiting)等六种状态。这六种状态对应Thread.State枚举类的状态。
当创建一个线程时,线程处于New状态;运行Thread.start()方法后,线程处于Runnable状态,
这个时候所有处于Runnable状态的线程并不能马上运行,而是需要先进入就绪(Ready)状态
等待线程调度,在获取到CPU后才能进入运行(Running)状态;
运行状态可随着不同条件转换称为New状态以外的其它状态:运行中的线程进入
Sychronized方法块或是同步方法时,获取不到锁或是获取锁失败就会进入Block状态,当获取
到锁时会从Block状态恢复到Ready状态;运行中的线程还会进入等待状态,等待状态分为有超
时时间的等待和无超时时间大的等待(有超时的等待:调用了Object.wait方法和Thread.join
方法;无超时的等待:调用了Thread.join方法和LockSupport.part方法。这两种等待可以调
用notify或upPark方法结束等待。)线程运行完成之后就进入Terminated(Dead)状态。
11.对象锁、方法锁、类锁
https://blog.csdn.net/zhujiangtaotaise/article/details/55509939
翻明白这个例子,它里面的所谓的对象锁其实就是方法锁,叫的名字比较郁闷。
强调一点,类锁对所有实例都起作用,比如双重检索模式的单例。
九.多线程自己学习的
1.守护线程和非守护线程(有空了解一下守护线程的使用场景)
非守护线程也叫用户线程
非守护线程:非守护线程包括常规的用户线程,比如我们实现的一个线程(没有调
用thread.setDaemon(true)),就是非守护线程。
守护线程,Java虚拟机在它所有非守护线程已经离开后自动离开。
守护线程:守护线程则是用来服务用户线程的,比如说GC线程。如果没有其他用
户线程在运行,那么就没有可服务对象,也就没有理由继续下去。
main()函数创建的线程也是守护线程
如何创建守护线程:使用thread.setDaemon(true)方法,该方法必须在thread.start()
之前设置,你不能把正在运行的常规线程设置为守护线程,否则
会跑出一个IllegalThreadStateException异常,如果线程是守护
线程,则isDaemon方法返回true。
十.线程数设置多少合适?
和cpu核数之间的关系
单核的cpu设置多线程也是合适的
cache线程池它的核心线程数为1,最大线程数是Integer的最大值,任务队列使用