目录
1:java内存模型
1.1:计算机的内存模型
2:Voliate关键字
2.1:voliate不能保证线程安全(可见性分析)
2.2:voliate禁止指令重排
3:java的线程
3.1:线程和进程
3.2:线程的状态
4:线程安全
4.1:synchronize关键字(悲观锁,阻塞同步)
4.2:Cpmpare And Swip(乐观锁,非阻塞同步CAS)
4.3:CAS怎么使用(显式调用和隐式调用)
5:锁优化(synchronize优化)
5.1:自旋和自适应自旋
5.2:锁消除
5.3:锁粗化
5.4:轻量级锁(绝大部分锁在整个周期都不存竞争)
5.5:偏向锁(无竞争的单线程执行,有竞争的时候才会释放锁)
5.6:偏向锁、轻量级锁、重量级锁对比
6: 什么是死锁?如何避免死锁?
6.1:什么是死锁
6.2:如何避免死锁?
在计算机的内存模型中cpu和内存之间的速度存在数量级所以引入了高速缓存,告诉缓存会导致到底以哪个处理器的缓存为主,同步到主内存,这个时候有有了缓存一致性协议,来保证缓存一致性。
指令重排:例如一下五行代码,前四行的在计算机cpu的执行顺序不一定是12345,也可以是13245或者34125,但是第五步的顺序不会变,这种指令重排不会影响最后的计算结果。
int a=1;
a++;
int b=5;
b++;
int c=a+b;
1.2:java内存模型
java的内存模型屏蔽掉了计算机硬件和操作系统的差异,但是没有限制处理器使用缓存和主内存进行交互,也没有限制编译器在执行过程中的指令重排这类优化措施。
voliate关键字有两个作用(可见性和禁止指令重排)
1:可见性:保证在多个线程的情况下,线程一把int a的值修改为5的时候,其他线程也能立即知道int a=5,实现的方法是线程1把int a=5,会把a=5的是立马通过缓存同步到主内存,然后其他线程使用a之前会从主内存刷新一次,得到被线程1修改的值为5.
2:有序性:有序性的意思的在本线程内观察,所有操作都是有序的(线程内是串行的),但是在另一个线程观察本线程,所有操作都是无序的(主要是指指令重排现象和工作内存与主内存同步)。voliate和synchronize两个关键字来保证线程操作之间的有序性。
预期结果:10个线程同时执行,每一个此线程都把i累加到1000.最后的结果应该是10000.
实际结果:多次运行,大部分结果都不足10000
结论分析:i++,不是原子性操作,线程1把i的值赋值的10,这个时候后线程2根据可见性也得到了10,但是线程1接着执行累加把i的值赋值到250,这个时候线程2依然拿到的值是10是过期数据,入栈++后的值为11,把11同步到了主内存,触发了线程1从主内存同步的得到值为11,这个时候会导致线程一的结果错误。
public class VoliteTest {
//voliate关键字保证了此变量在各个线程中是一致的
public static volatile int id=1;
public static void main(String[] args) throws InterruptedException {
// TODO Auto-generated method stub
Thread[] ths=new Thread[10];
for(int i=0;i<10;i++) {
//10个线程同时执行
ths[i]=new Thread(new Runnable() {
@Override
public void run() {
// TODO Auto-generated method stub
add();
}
});
ths[i].start();
}
//累加线程执行完毕
while (Thread.activeCount()>1) {
Thread.yield();
}
System.out.println("========================");
System.out.println("ID的值是:"+id);
}
//该方法可以通过加锁实现线程安全
public static void add() {
for(int i = 0;i<1000;i++) {
//id++不是原子性的
//第一步:假如线程1同步id=5,这个时候要++,其他线程切换了进来
//第二部:其他比如线程2同步id=5,执行了很多操作,id=100了
//第三部:线程1执行++,得到了id=6,这个时候的id为5已经是过期数据了,
//第四部:线程1将结果6同步到内存共享,其他的线程得到id=6.会导致最后的值不等于10001
id++;
}
}
}
voliate保证cpu能够保证在这个关键字之前和之后的代码不会指令重排,保证按照书写顺序执行代码。
之所以能实现指令重排是因为voliate的可见性决定的,这个关键字保证了从缓存到主内存之间的同步,就像内存之间的一道屏障,保证了之前和之后的代码无法越过这个 内存栅栏。
单例代码案例:保证线程安全和只有一个实例
public class Danli {
private volatile static Danli danli;
//私有构造,防止通过外部new创建对象
private Danli() {
}
//懒汉模式 需要的时候get
//(方法加锁导致多线程效率低)
public static synchronized Danli getDanli() {
if(danli==null) {
//在此处判断加锁,防止线程1判断为空后,线程二创建了实例
//在加锁方法之内再次判断一次
synchronized (Danli.class) {
if(danli==null) {//再次判断
danli=new Danli();//内存屏障,保证写完之后其他线程读取
//1:在工作内存创建,(store存储)到主内存
//在此之间可能有其他线程插入
//2:主内存的danli(write写入)
//这是一个内存栅栏
}
}
}
return danli;
}
}
进程:操作系统分配资源的基本单位
线程:cpu执行和调度的基本单位
区别:
1:开销方面:进程具有独立的代码和数据空间(程序上下文),程序之间切换会有比较大的开销,线程是轻量级的进程,共享进程的代码可数据空间,线程具有自己独立的运行栈和程序计数器,切换开销小。
2:所处环境:系统可以有多个进程(程序实例),每一个进程可以有多个线程同时执行工作。cpu同时只能执行一个线程。
3:内存分配:系统为我们开发的一个网站后台(进程)分配出一定的内存,里边的线程会共享进程分配的这个内存空间、文件I/O等资源,并且都有自己的栈,但是线程之间相互影响较大,数据会共享。进程之间不影响。
4:通信要求:进程之间通信需要协议支持,代码复杂,线程之间通信简单,互相调用即可。
线程的状态有5种:
1:新建(New):使用new关键字创建尚未启动的线程。new Thread();就是新建一个线程
2:运行(Ruanable):Runable包含线程的运行和就绪两种状态,也就是此时的线程正在运行或者在等待cpu为其分配执行时间
3:无限等待(Waiting):这个时候的线程不会被CPU分配执行时间,他们要等待被其他线程显示的唤醒。一下方法会导致线程无限等待
没有设置Timeout参数的Object.wait()方法
没有设置Timeout参数的Thread.join()方法
LockSuport.park()方法
4:超时等待(Timed Wating):这种状态不会被cpu分配执行时间。不过无需等待被其他线程唤醒,在一定时间之后会被系统自动唤醒。以下方法会进入超市等待。
Thread.sleep()
设置Timeout参数的Object.wait()方法
设置Timeout参数的Thread.join()方法
5:阻塞(Blocked):阻塞的与等待的意思是阻塞是在等待获取一个排他锁,这个事件将在其他的线程使用这个锁的时候发生
6:结束(Terminated):线程执行完毕,结束执行
悲观锁:我们认为不加锁的情况下,程序就一定会出错,所以比较悲观,一定要加锁,使用synchronize关键字来解决
synchronize:通过底层特殊的字节码指令,给对象加锁,通过互斥类维持同步,即线程安全。也是悲观锁(我们认为不加锁的话多线程程序就一定会)
优点:基本的互斥同步关键字,使用简单,通用性强,大家都在用
缺点:线程抢占资源的时候,会把没有抢占到资源的线程会进入阻塞状态,线程挂起,切换线程上下文的是一件很耗资源的问题。有时候线程的上下文切换耗时会比要用单个线程执行的效率更低。
我们知道在多线程的环境下使用synchronize关键字可以保证当前只有一个线程在执行修改变量,这个关键字在经过编译之后会在同步代码块的前后有monitorenter和monitorexit自节码指令,这两个指令会需要一个引用类型的的参数来指明要锁定和加锁的对象,如果对象没有被锁定,则该线程获取锁,把锁的计数器+1,在执行完代码的时候会把锁的计数器-1.
我们用代码来说明
public static void main(String[] args) throws InterruptedException {
// TODO Auto-generated method stub
//线程安全,方法有锁
StringBuffer buffer=new StringBuffer();
for(int i=0;i<5;i++) {
//循环创建5个线程同时执行,测试加锁的方法
Thread thread=new Thread(new Runnable() {
@Override
public void run() {
//1:每一个线程都要执行加锁的append方法
//2:但是加锁的append方法在编译之后有monitorenter和monitorexit字节码指令
//3:当某一个线程执行加锁的方法的时候,有次字节码指令的方法的时
//4:我们new的buffer的对象头部会被加锁,计数器+1
//5:这个时候第二个线程看到对象的计数器数字为1,就不能执行append方法,进入如阻塞状态
//6:直到线程1执行结束,将所释放,计数器-1.这个时候才允许其他线程来执行
buffer.append("你是谁?");
}
});
thread.start();
}
System.out.println("========================");
System.out.println("拼接的值是:"+buffer.toString());
}
前面我们知道了悲观锁,但是随着计算机指令的发展,我们有了另外一种选择,乐观锁。
乐观锁:乐观锁是一种基于冲突检测的乐观并发策略,也就是多个新程的情况先进性数据操作,如果没有其他线程争用共享的数据,那么就操作成功,如果有线程争用,就采取其他的策略,比如不断重试,不用挂起线程。这种就是乐观锁。
CSA:CAS的意思是比较互换,是一组计算机指令,CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。主要有三个元素V内存位置,A预期的旧值,B新值,CAS的特点就是这个比较和交换过程是一个原子操作,在CPU内内部执行,效率高,祛除了内核就多线程的切换。
do{
备份旧数据;
基于旧数据构造新数据;
}while(!CAS( 内存地址,备份的旧数据,新数据 ))
我们假设一种情况,用来理解CAS的底层原理
1:当前i=1(是已经存在的旧值A);
2:线程1得到i的内存位置,指向的是i=1;
3:执行i=i+3,得到的新值i=4;
4.1:这个时候线程1对比旧值1是否变化,如果没有变化证明当前只有自身线程修改了i的值是i=4,直接返回新值的地址指向4
4.2:如果线程1对比发现旧值1发生了变化,证明在i=i+3的过程中其线程修改了i,这个时候就要直接返回以前的值1或者是其他线程修改后得到的其他任意值了。
CAS依赖于X86的cpu指令集,所以是依赖于计算机处理器的
1:隐式使用
在JDK1.6之后我们不用编写代码,在int、long和对象的引用等类型上都公开了CAS的操作,也就是说我们在JDK1.5之前使用synchronize关键字的开销很大,线程竞争的时候要挂起。但是jdk1.6之后SUN在synchronize关键字上下了很多功夫,会自动使用不同级别的锁优化技术进行了优化,有自旋锁,所粗话,轻量级锁,偏性锁等不同级别的锁定状态,这些不同的级别的优化技术中都用到CAS,也就是说底层虚拟机自动帮你用了CAS
2:显式使用
在原子类变量中,如java.util.concurrent.atomic中的AtomicXXX,都使用了这些底层的JVM支持为数字类型的引用类型提供一种高效的CAS操作,而在java.util.concurrent中的大多数类在实现时都直接或间接的使用了这些原子变量类。我们可以显示的创建这些原子类调用,使用CSA操作。
代码案例如下
public class Cas_test1 {
int i=0;
//原子类,底层实现CAS算法,不需要加锁也能实现累加
AtomicInteger atomicInteger=new AtomicInteger(0);
public static void main(String[] args) {
Cas_test1 cas=new Cas_test1();
// TODO Auto-generated method stub
List threads=new ArrayList();
long start=System.currentTimeMillis();
//1万个线程调用count方法
for (int i = 0; i < 1000; i++) {
Thread thread=new Thread(new Runnable() {
@Override
public void run() {
// TODO Auto-generated method stub
cas.count();
cas.safecount();
}
},"自定义名字");
threads.add(thread);
}
for( Thread ts:threads) {
ts.start();
}
System.out.println("非线程安全i的值是:"+cas.i);
System.out.println("线程安全:"+cas.atomicInteger.get());
System.out.println("耗时:"+(System.currentTimeMillis()-start));
}
//线程非安全,执行多次可能会出现数据异常
public void count() {
i++;
}
//线程安全原子类
public void safecount() {
atomicInteger.incrementAndGet();
}
}
输出结果如下:
在CAS中可能存在ABA的问题,该问题是一个变量V初次读取的时候它的值是A,在这个时候其他的线程把他的值复制成B,然后又修改成了A,这个时候ABA算法可能会认为他的值没有用发生变化。。我们可以通过控制变量的版本号来解决问题,在JUC包下的AtomicStampedReference来解决。但是ABA一般情况不会影响业务,我们也可以使用传统的互斥同步会比较好。
在锁优化之前我们要知道有什么缺点,需要我们优化
多线程的缺点主要有两个
1:多个线程竞争的时候线程会挂起,其他线程需要挂起进入阻塞状态,然后等待唤醒,也就是常说的上下文切换,这个很耗时
2:经过大量的实践。我们发现就是加锁的情况下,线程实际上发生竞争的概率很小,恰巧在同一个节点修改到同一个数据的导致数据出错的概率比较小,因为计算机cpu很快,很难撞到一起。但是也不绝对。
所以针对上下文切换和竞争问题就做出了优化
两个线程竞争同一个锁的时候,没有得到资源的线程先不要挂起,就像去银行办理业务,你前边有人的话,不要立马回家(相当于挂起),在银行大厅等待一会,看看有没有机会。这就是自旋,自旋会CPU的资源,一般自旋有个次数限制,在在虚拟机可以通过参数设置,自适应自旋就是虚拟机动态的调整自旋次数或者是挂起。-xx:PrelockSpin来更改自旋次数
菜鸡程序员可能会乱加锁,比如一段代码不涉及数据共有,都是一些局部变量。或者在不自觉的使用到了锁。这个时候虚拟机在编译的时候会检查代码,自动把锁进行消除
public String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
在JDK1.6之后会优化成如下代码,这些字符串是局部辨明,不存在逃逸,所以会进行锁的消除
public String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
//append方法都是加锁的方法
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
如果我们写出了类是上边的三个append方法,都加了锁,或者是代码里边零碎方法多次加锁,会自动把锁粗化到最大范围,比如上边的三个append会优化成一个锁锁着这三个方法。避免多次线程无效切换个挂起房费时间。JVM的底层还是很牛逼的
轻量级锁:基于绝大部分锁在运行的时候都不存在竞争,通过CAS操作消除互斥量,线程不会阻塞,多线程通过自旋等待获取锁。
对象头部(Mark Word):在了解轻量级锁之前而我们在首先要知道对象的构成,我们知道在JVM中对象那个头部包含对象的年龄分代信息,哈希码和锁状态,具体如下,多线程的实现依赖于对象头部的锁标示为,长度为2bit用于存储对象的锁状态
不同的锁标示位对应了不同的状态,对照码表如下:
轻量级锁的的逻辑如下:
1:我们知道在没有线程占用的时候堆中的对象头包含GC分代信息,哈希码,锁标识等信息,(官方称为Mark Word),锁初始状态是01。
2:线程1进入同步代码块的时候,会在线程所在的帧中创建一个锁空间(Lock Record),并且把对象头信息copy到帧的锁空间,并且加上一个前缀Displaced.
3:然后使用CAS操作尝试将Mark Word的地址指向锁空间的指针。这个时候就是对象处于轻量级锁的状态,并且指向该线程。并且把堆中的Mark Word的状态改成00。标识对象处于轻量级锁,警示其他线程
4:轻量级锁解锁的过程,在当前同步代码执行完毕后,将栈中锁空间的含有前缀的01对象头信息和堆中已经处于轻量级锁的00信息进行CAS交换,如果交换成功处于解锁状态,然后唤醒其他的线程。
5:轻量级锁的膨胀过程,如果当前线程占用到了锁,堆中的状态为00,其他线程进来,会自旋等待释放锁,如果这个时候除了当前占用所的线程,来了两条或者以上的线程竞争该轻量级锁,那么轻量级锁不在有效,需要膨胀为重量级锁标识变成10,那么线程进入挂起阻塞状态。
堆栈和对象状态如下:
获取锁之前
获取锁之后
在轻量级锁有CAS操作消除互斥量,是线程不用挂起,那么偏向锁连CAS都不做了(第一次需要,以后不需要了CAS了)。
偏向锁逻辑如下:
1:线程第一次获取锁对象的时候,将堆中的对象头字段改成01标识为偏向模式。使用CAS把线程id添加到对象头部,以后这个线程只需要看看头部有没对应的线程号,即可直接进入代码块。这种策略是假设无竞争的情况。
2:如果其他的线程获取这个锁的时候,偏性锁就失效了,恢复到未锁定状态,或者升级为偏性锁模式。
3:对于并发比较多的情况,所竞争激烈,可以直接禁用偏性锁。-xx:-UserBasedLocking
优点 | 缺点 | 备注 | |
偏向锁 | 单个线程占用,有竞争力的情况才会结束偏向模式 | 多线程情况下偏性模式是多余,速度会变慢 | 在发生竞争的时候才会释放锁,适合单线程,在java虚拟机中通过-XX:UserBaisedLocking禁用 |
轻量级锁 | 竞争线程首先会通过自旋等待,当多个线程同时竞争的时候才会锁升级发生阻塞 | 如果自旋始终得不到资源的话,会好得CPU资源 | 代码速度执行块,多个线程自旋切换 |
重量级锁 | 线程不会自旋,不消耗CPU | 线程挂起,切换线程上下文耗费资源 | 代码执行速度慢,吞吐量大 |
两个或者两个以上的线程在执行过程中,互相竞争,导致彼此阻塞,没有外力的作用,程序将一直阻塞下去,这个时候系统就处于死锁状态。
如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
代码示例如下:
package com.thit.sisuo;
public class Test1 {
private static String a="A";
private static String b="B";
public static void main(String[] args) {
// TODO Auto-generated method stub
Thread thread1=new Thread(new Runnable() {
@Override
public void run() {
//对a加锁
synchronized (a) {
try {
//线程睡眠一秒,当前已经对a加了锁,
//让线程2执行,对b开始加锁,造成互斥条件
Thread.currentThread().sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
//这个时候需要获取B,但是B已经加锁了
synchronized (b) {
System.out.println("线程1");
}
}
}
});
Thread thread2=new Thread(new Runnable() {
@Override
public void run() {
//对b加锁
synchronized (b) {
//想要获取a的锁,处于死锁状态
synchronized (a) {
System.out.println("线程2");
}
}
}
});
thread1.start();
thread2.start();
//thread1.start();
}
}
首先我们知道造成死锁有四个条件
1:互斥条件:该资源任意时刻只能有一个线程占有(上边例子中的ab都是加锁的,只能有一个资源占有)
2:请求与保持条件:一个线程对资源进行请求的时候,到资源保持不释放(线程12互相请求ab确步释放加锁的资源)
3:不剥夺条件:线程是有的资源,在没有使用完毕之前,不嗯能被其他的线程剥夺,只能等待自己使用完毕之后释放。
4:循环等待条件:多个线程形成收尾相接的贪吃蛇循环模式,等待资源释放
我们想要避免死锁,只需要破坏其中的一个条件即可,不适用嵌套锁,或者线程2先启动等等都能避免死锁。