JAVA多线程和内存模型常见问题

Java线程具有五种基本状态

  • 新建状态(New):当线程对象对创建后,即进入了新建状态,如:Thread t = new MyThread();
  • 就绪状态(Runnable):当调用线程对象的start()方法(t.start();),线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了t.start()此线程立即就会执行;
  • 运行状态(Running):当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;
  • 阻塞状态(Blocked):处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才 有机会再次被CPU调用以进入到运行状态。
  • 死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

其中根据阻塞产生的原因不同,阻塞状态又可以分为三种:

  1. 等待阻塞:运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态;
  2. 同步阻塞:线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态;
  3. 其他阻塞:通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。

java内存模型的三种属性

原子性(互斥性):在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程中的协调机制,这样在同一时间只有一个线程对需同步的代码块(复合操作)进行访问。互斥性我们也往往称为操作的原子性,原子是世界上的最小单位,具有不可分割性。非原子操作都会存在线程安全问题,需要我们使用同步技术(sychronized)来让它变成一个原子操作。java的concurrent包下提供了一些原子类,我们可以通过阅读API来了解这些原子类的用法。比如:AtomicInteger、AtomicLong、AtomicReference等。

可见性:必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作从而引起不一致,比如:用volatile修饰的变量,就会具有可见性 。volatile修饰的变量不允许线程内部缓存和重排序,即直接修改内存,对其他线程是可见的。但是这里需要注意一个问题,volatile只能让被他修饰内容具有可见性 ,但不能保证它具有原子性。

可排序性:从操作线程的角度看来,如果所有的指令执行都是按照普通顺序进行,那么对于一个顺序运行的程序而言,可排序性也是顺序的。从其他操作线程的角度看来,某个线程自身的排序特性可能使得它不定地访问执行线程的可见域,而使得该线程对本身在执行的线程产生一定的影响。可以通过同步方法和同步块的相对排序(volatile、synchronized等)

CAS

CAS不是java特有的,而是操作系统需要保证的。CAS指令在Intel CPU上称为CMPXCHG指令,它的作用是将指定内存地址的内容与所给的某个值相比,如果相等,则将其内容替换为指令中提供的新值,如果不相等,则更新失败。

CAS需要注意的以下问题:

  • 在线程抢占资源特别频繁的时候(相对于CPU执行效率而言),会造成长时间的自旋,耗费CPU性能。
  • ABA问题,即在更新前的值是A,但在操作过程中被其他线程更新为B,又更新为A。这时当前线程认为是可以执行的,其实是发生了不一致现象,如果这种不一致对程序有影响(真正有这种影响的场景很少,除非是在变量操作过程中以此变量为标识位做一些其他的事,比如初始化配置),则需要使用AtomicStampedReference(除了对更新前的原值进行比较,也需要用更新前的stamp标志位来进行比较)。
  • 只能对一个变量进行原子性操作。如果需要把多个变量作为一个整体来做原子性操作,则应该使用AtomicReference来把这些变量放在一个对象里,针对这个对象做原子性操作。

重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。

重排序类型

  1. 编译器优化的重排序,编译器在不改变单线程程序语义的前提下,可以重新安排语义。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

as-if-serial语义

不管怎么重排序,(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可以被编译器和处理器重排序。

happens-before

如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的记过一致,那么这种重排序不非法(也就是说,JMM允许这种重排序)。

禁止重排序

volatile

内存屏障进行解决的,所谓的内存屏障是一个cpu命令,它有两个作用保证特定的执行顺序,保证可见性,通过在volatile 指令前后增加内存屏障从而解决指令重排问题。

synchronized

与volatile类似,也是使用了内存屏障。

final

通过为final域增加写和读重排序规则,可以为Java程序员提供初始化安全保证,只要对象时正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步(指lock和volatile的使用)就可以保证任意线程都能看到这个final域在构造函数中被初始化之后的值。

对于final域,编译器和处理器要遵守两个重排序规则。

  1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。(防止拿到对象时,final域还未赋值)
  2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

内存屏蔽

一个线程是由一个CPU核来处理的,CPU为了继续挖掘并行运算能力,出现了流水线技术,即每个内核有多个电路,调度器将串行指令分解为多步,并将不同指令的各步操作结果叠加,从而实现并行处理,指令的每步都由各自独立的电路来处理,最后汇总处理。这在相当程度上提高了并发能力。

操作作系统提供一种机制以禁用重排序,将决定是否重排序的选择权交给应用程序。如果应用程序不允许重排序,则插入相应的内存屏障指令将其禁用。

volatile与synchronized的区别

volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取。synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。

  1. volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的
  2. volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性
  3. volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
  4. volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。

Lock与synchronized的区别

  • ReentrantLock 拥有Synchronized相同的并发性和内存语义,此外还多了锁投票,定时锁等候和中断锁等候。
  • 如果使用 synchronized ,如果A不释放,B将一直等下去。如果使用ReentrantLock,如果A不释放,可以使B在等待了足够长的时间以后,中断等待,而干别的事情。
  • synchronized是在JVM层面上实现的,不但可以通过一些监控工具监控synchronized的锁定,而且在代码执行时出现异常,JVM会自动释放锁定,但是使用Lock则不行,lock是通过代码实现的,要保证锁定一定会被释放,就必须将unLock()放到finally{}中
  • 在资源竞争不是很激烈的情况下,Synchronized的性能要优于ReetrantLock,但是在资源竞争很激烈的情况下,Synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态。

你可能感兴趣的:(JAVA多线程和内存模型常见问题)