从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
总结:并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,充分的利用多核CPU资源。
原子性(Atomicity):在一次或多次操作中,要么所有的操作都执行并且不会受其他因素干扰而中断,要么所有的操作都不执行
可见性:一个线程对共享变量的修改,其他线程能够立刻看到。(synchronized,volatile)
有序性:程序执行的顺序按照代码的先后顺序执行。(指令重排:处理器为了提高程序运行效率,处理器根据指令之间的数据依赖性,可能会对指令进行重排序,单线程下可以保证程序最终执行结果和代码顺序执行的结果是一致的,但是多线程下有可能出现问题)。
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
举个简单的例子,看下面这段代码:
//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i = 10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10。线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。这就是可见性问题。
对于可见性,Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
即程序执行的顺序按照代码的先后顺序执行。
举个简单的例子,看下面这段代码:
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后执行。那么它靠什么保证的呢?进行重排序时是会考虑指令之间的数据依赖性。虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。
从上面可以看出,在Java内存模型中,允许编译器和处理器对指令进行重排序,
但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。
你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。 (不一定是同时的)
你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。
并发的关键是你有处理多个任务的能力,不一定要同时。
并行的关键是你有同时处理多个任务的能力。
1)Java 中的线程对应是操作系统级别的线程,线程数量控制不好,频繁的创建、销毁线程和线程间的切换,比较消耗内存和时间。
2)容易带来线程安全问题。如线程的可见性、有序性、原子性问题,会导致程序出现的结果与预期结果不一致。
3)多线程容易造成死锁、活锁、线程饥饿等问题。此类问题往往只能通过手动停止线程、甚至是进程才能解决,影响严重。
4)对编程人员的技术要求较高,编写出正确的并发程序并不容易。
5)并发程序易出问题,且难调试和排查;问题常常诡异地出现,又诡异地消失。
Java 5.0 提供了java.util.concurrent(简称JUC)包,在此包中增加了在并发编程中很常见的实用工具类,用于定义类似于编程的自定义子系统,包括线程池、异步IO和轻量级任务框架。提供可调的、灵活的线程池。还提供了设计用于多线程上下文的Collection实现等。
JMM其实并不像JVM内存模型一样是真实存在的,它只是一个抽象的规范。在不同的硬件或者操作系统下,对内存的访问逻辑都有一定的差异,而这种差异会导致同一套代码在不同操作系统或者硬件下,得到了不同的结果,而JMM的存在就是为了解决这个问题,通过JMM的规范,保证Java程序在各种平台下对内存的访问都能得到一致的效果。
计算机在执行程序的时候,每条指令都是在 CPU 中执行的,而执行的时候,又免不了和数据打交道,而计算机上面的数据,是存放在计算机的物理内存上的。当内存的读取速度和CPU的执行速度相比差别不大的时候,这样的机制是没有任何问题的,可是随着CPU的技术的发展,CPU的执行速度和内存的读取速度差距越来越大,导致CPU每次操作内存都要耗费很多等待时间。
为了解决这个问题,初代程序员大佬们想到了一个的办法,就是在CPU和物理内存上新增高速缓存,这样程序在运行过程中,会将运算所需要的数据从主内存复制一份到CPU的高速缓存中,当CPU进行计算时就可以直接从高速缓存中读数据和写数据了,当运算结束再将数据刷新到主内存就可以了。
随着时代的变迁,CPU开始出现了多核的概念,每个核都有一套自己的缓存,并且随着计算机能力不断提升,还开始支持多线程,最终演变成多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的 Cache 中保留一份共享内存的缓冲,我们知道多核是可以并行的,这样就会出现多个线程同时写各自的缓存的情况,导致各自的 Cache 之间的数据可能不同。
总结下来就是:在多核 CPU 中,每个核的自己的缓存,关于同一个数据的缓存内容可能不一致。
重排序指的是在执行程序时,为了提高性能,从源代码到最终执行指令的过程中,编译器和处理器会对指令进行重排的一种手段。
下图为从源代码到最终指令示意图
重排序的分为3种
1)编译器优化的重排序:编译器在不改变单线程程序语义(as-if-serial)的的前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序:现在处理器采用指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3)内存系统的重排序:由于处理器使用了存储和读写缓冲区,这使得加载和存储操作看上去乱序执行。
1.编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2.指令级并行的重排序。现代处理器采用了指令级并行技术(ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3.内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
4.这些重排序对于单线程没问题,但是多线程都可能会导致多线程程序出现内存可见性问题。
数据依赖性:编译器和处理器在重排序时,针对单个处理器中执行的指令序列和单个线程中执行的操作会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
遵守as-if-serial 语义:不管编译器和处理器为了提高并行度怎么重排序,(单线程)程序的执行结果不能被改变。
区别:
as-if-serial定义:无论编译器和处理器如何进行重排序,单线程程序的执行结果不会改变。
happens-before定义:一个操作happens-before另一个操作,表示第一个的操作结果对第二个操作可见,并且第一个操作的执行顺序也在第二个操作之前。但这并不意味着Java虚拟机必须按照这个顺序来执行程序。如果重排序的后的执行结果与按happens-before关系执行的结果一致,Java虚拟机也会允许重排序的发生。
happens-before关系保证了同步的多线程程序的执行结果不被改变,as-if-serial保证了单线程内程序的执行结果不被改变。
相同点:happens-before和as-if-serial的作用都是在不改变程序执行结果的前提下,提高程序执行的并行度。
不可变对象即对象一旦被创建,它的状态(对象属性值)就不能改变。
不可变对象的类即为不可变类。Java 平台类库中包含许多不可变类,如 String、基本类型的包装类、BigInteger 和 BigDecimal 等。
不可变对象保证了对象的内存可见性,对不可变对象的读取不需要进行额外的同步手段,提升了代码执行效率。
1.保证变量写操作的可见性;
2.保证变量前后代码的执行顺序;
不能。volatile不能保证原子性,只能保证线程可见性,可见性表现在当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
从实践角度而言,volatile的一个重要作用就是和CAS结合,保证了原子性,详细的可以参见java.util.concurrent.atomic包下的类,比如AtomicInteger。
被volatile修饰的变量被修改时,会将修改后的变量直接写入主存中,并且将其他线程中该变量的缓存置为无效,从而让其它线程对该变量的引用直接从主存中获取数据,这样就保证了变量的可见性。
但是volatile修饰的变量在自增时由于该操作分为读写两个步骤,所以当一个线程的读操作被阻塞时,另一个线程同时也进行了自增操作,此时由于第一个线程的写操作没有进行所以主存中仍旧是之前的原数据,所以当两个线程自增完成后,该变量可能只加了1。因而volatile是无法保证对变量的任何操作都是原子性的。
能,Java 中可以创建 volatile 类型数组,但如果多个线程改变引用指向的数组,将会受到 volatile 的保护,如果多个线程改变数组的元素内容,volatile 标示符就不能起到之前的保护作用了。
volatile变量可以确保先行关系,即写操作会发生在后续的读操作之前,但它并不能保证原子性。例如用volatile修饰count变量那么count++操作就不是原子性的。
而AtomicInteger类提供的atomic方法可以让这种操作具有原子性如getAndIncrement( )方法会原子性的进行增量操作把当前值加- ,其它数据类型和引用变量也可以进行相似操作。
原子操作是指不会被线程调度机制打断的操作,这种操作一旦开始,就一直运行到结束,中间不会有任何线程上下文切换。原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序不可以被打乱,也不可以被切割而只执行其中的一部分,将整个操作视作一个整体是原子性的核心特征。
而 java.util.concurrent.atomic 下的类,就是具有原子性的类,可以原子性地执行添加、递增、递减等操作。比如之前多线程下的线程不安全的 i++ 问题,到了原子类这里,就可以用功能相同且线程安全的 getAndIncrement 方法来优雅地解决。
原子类的作用和锁有类似之处,是为了保证并发情况下线程安全。不过原子类相比于锁,有一定的优势:
粒度更细:原子变量可以把竞争范围缩小到变量级别,通常情况下,锁的粒度都要大于原子变量的粒度。
效率更高:除了高度竞争的情况之外,使用原子类的效率通常会比使用同步互斥锁的效率更高,因为原子类底层利用了 CAS 操作,不会阻塞线程。原子类的作用和锁有类似之处,是为了保证并发情况下线程安全。不过原子类相比于锁,有一定的优势:
粒度更细:原子变量可以把竞争范围缩小到变量级别,通常情况下,锁的粒度都要大于原子变量的粒度。
效率更高:除了高度竞争的情况之外,使用原子类的效率通常会比使用同步互斥锁的效率更高,因为原子类底层利用了 CAS 操作,不会阻塞线程。
AtomicInteger与AtomicLong:它们的底层实现使用了CAS锁,不同点在于AtomicInteger包装了一个Integer型变量,而AtomicLong包装了一个Long型变量。
LongAdder:它的底层实现是分段锁+CAS锁。
atomic代表的是concurrent包下Atomic开头的类,如AtomicBoolean、AtomicInteger、AtomicLong等都是用原子的方式来实现指定类型的值的更新,它的底层通过CAS原理解决并发情况下原子性的问题,在jdk中CAS是Unsafe类中的api来实现的。
CAS,全称为Compare and Swap,即比较-替换,实现并发算法时常用到的一种技术。假设有三个操作数:内存值V、旧的预期值A、要修改的值B,当且仅当预期值A和内存值V相同时,才会将内存值修改为B并返回true,否则什么都不做并返回false。当然CAS一定要volatile变量配合,这样才能保证每次拿到的变量是主内存中最新的那个值,否则旧的预期值A对某条线程来说,永远是一个不会变的值A,只要某次CAS操作失败,永远都不可能成功。
以AtomicInteger为例,说明CAS的使用与原理。首先atomicIngeter初始化为5,调用对象的compareAndSet方法来对比当前值与内存中的值,是否相等,相等则更新为2019,不相等则不会更新,compareAndSet方法返回的是boolean类型。
import java.util.concurrent.atomic.AtomicInteger;
public class CASDemo {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(5);
System.out.println(atomicInteger.compareAndSet(5,2019)+" \t current "+atomicInteger.get());
System.out.println(atomicInteger.compareAndSet(5,2014)+" \t current "+atomicInteger.get());
}
}
分析:第一次调用,内存中的值是5,通过对比相等更新为2019,输出 true current 2019,第二次调用时,内存重点的值已经更新为2019,不相等不更新内存中的值,输出 false current 2019。
1)CAS存在一个很明显的问题,即ABA问题。
如果变量V初次读取的时候是A,并且在准备赋值的时候检查到它仍然是A,那能说明它的值没有被其他线程修改过了吗?如果在这段期间曾经被改成B,然后又改回A,那CAS操作就会误认为它从来没有被修改过。针对这种情况,java并发包中提供了一个带有标记的原子引用类AtomicStampedReference,它可以通过控制变量值的版本来保证CAS的正确性。
2)只能保证一个共享变量的原子性。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无 法保证操作的原子性,这个时候就可以使用锁来保证原子性。