为什么使用多线程
- 多线程也就是多个线程在运行,可以提高cpu的使用率,单线程执行程序时,阻塞时一直等着,多线程执行程序时,一个线程阻塞,其他线程继续执行。
并发与并行
- 并发就是在一个时间段内,多个线程交替获取时间片运行,由于交替的间隔比较短,所以可以认为是同时执行。
- 并行是真正意义上的同时执行,比如多个cup同一时间运行多个线程,不存在抢占时间片的情况。
- 一般线程数都是多于机器的cup数,所以我们的程序都是叫高并发而不是高并行
高并发带来的问题以及解决方法
- 多线程运行确实提高了程序的运行效率,但是也带来问题,举个例子,比如我们对一个变量进行累加,创建两个线程来实现,线程1读取到变量值为1,进行累加,但是这个时候还没有真正的写入到内存,线程2就以及读取了变量值1,两个线程累加后的值为2,这就和我们的期望不符了。这就是多线程带来的线程安全问题
- 出现线程安全问题主要是在主内存和线程的工作内存之前的读写出现的原子性,可见性,有序性的问题,只要解决了原子性,可见性,有序性就能保证线程的安全。
并发变成三个特性
- 原子性:一个操作(可能包含多个步骤)不可被中断,不能只执行一部分
- 可见性:一个线程修改了一个共享变量值其他线程能够立即看到这个修改后的值
- 有序性:程序的执行顺序按照代码的顺序执行,jvm的编译器可能会进行指令重排,打乱了代码执行的顺序,有序性保证了按照代码的顺序执行
synchronized
-
synchronized关键字可以用于修饰方法,代码块,类。synchronized是一种排他锁,可重入锁,被
synchronized
修饰的代码块及方法,在同一时间,只能被单个线程访问 -
对新创建的对象进行加锁(这种方式还要new一个对象出来比较麻烦)
public class SyncTest { private int count = 0; private Object object = new Object(); public void sync() { synchronized (object) { count++; System.out.println(count); } } }
-
对当前对象进行加锁
public class SyncTest { private int count = 0; public void sync() { synchronized (this) {// 线程想要执行代码块中代码必须先拿到this的锁 count++; System.out.println(count); } } public synchronized void sync(){ //等同于代码块synchronized (this) count++; System.out.println(count); } }
-
对静态方法加锁
public class SyncTest { private int count = 0; public static void sync() { synchronized (SyncTest.class) {// 静态方法没有this对象 count++; System.out.println(count); } } public synchronized static void sync(){ //等同于代码块synchronized (SyncTest.class) count++; System.out.println(count); } }
-
出现异常后会自动释放锁,如果不想锁被释放,try/catch
-
可重入性
- 一个同步方法调用另一个同步方法,两个方法加的是同一把锁,也是同一个线程执行,这个时候申请仍然会获取该对象的锁。
-
底层实现
-
对于同步代码块底层实现使用了monitorenter和monitorexit指令,monitorenter指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置,JVM需要保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个monitor与之相关联,当且一个monitor被持有之后,他将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权,即尝试获取对象的锁;同一个线程每获取一次锁,锁计数器就会加1,释放一次锁,计数器就会-1,这就是可重入性。
-
在JVM中,对象在内存中的布局分为三块区域:对象头、实例变量和填充数据。如下:
-
实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
-
填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。
-
**对象头:**Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。其中Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。
-
Mark Word:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
-
Java虚拟机对synchronize的优化:
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级,关于重量级锁,前面我们已详细分析过,下面我们将介绍偏向锁和轻量级锁以及JVM的其他优化手段。
-
偏向锁
- 偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
-
轻量级锁
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
-
自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。
-
重量级锁,直接去找操作系统申请锁
-
锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。
/** * 消除StringBuffer同步锁 */ public class StringBufferRemoveSync { public void add(String str1, String str2) { //StringBuffer是线程安全,由于sb只会在append方法中使用,不可能被其他线程引用 //因此sb属于不可能共享的资源,JVM会自动消除内部的锁 StringBuffer sb = new StringBuffer(); sb.append(str1).append(str2); } public static void main(String[] args) { StringBufferRemoveSync rmsync = new StringBufferRemoveSync(); for (int i = 0; i < 10000000; i++) { rmsync.add("abc", "123"); } } }
-
-
volatile
-
volatile有两层作用
- 保证了线程可见性
public class VolatileTest{ /*volatile*/ boolean running = true; public void running(){ while(running){ System.out.println("循环"); } } public static void main(String[] args){ VolatileTest vt = new VolatileTest(); new Thread(vt::running, "t1").start(); t.running = false; } }
-
上面的代码会不会一直待while里面循环呢?是有可能的。每个线程有自己的工作内存,修改变量的时候从主内存复制一份到自己的工作内存,修改完之后在回写到主内存。现在如果线程t1改完之后还没有回写到主内存就去干别的事情了,线程1不知道主线程修改了,所以会一直执行下去。
- 使用volatile之后就不一样了,会强制线程将修改的值立即回写主内存
- 使用volatile后,线程t1工作内存的变量running无效,所以再次读取的时候会从主内存取得
-
禁止指令重排序
-
volatile关键字禁止指令重排序有两层意思:
1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
2)在进行指令优化时,不能将在volatile变量前的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
//x、y为非volatile变量 //flag为volatile变量 x = 2; //语句1 y = 0; //语句2 flag = true; //语句3 x = 4; //语句4 y = -1; //语句5
-
由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。
并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的
-
-
-
有一点需要注意,volatile不能保证原子性
-
volatile到底如何保证可见性和禁止指令重排序的
-
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
2)它会强制将对缓存的修改操作立即写入主存;
3)如果是写操作,它会导致其他CPU中对应的缓存行无效。
-
CAS(compare and swap)
- cas是一种乐观锁,也叫无锁优化,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。CAS是一种非阻塞式的同步方式。
- CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作
- ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。
摘自:https://blog.csdn.net/caisongcheng_good/article/details/79916873
https://www.cnblogs.com/mingyao123/p/7424911.html