并发编程---进程、线程安全

原文:https://juejin.cn/post/6844903502154563597

在 java 中,所有的变量(实例字段,静态字段,构成数组的元素,不包括局部变量和方法参数)都存储在主内存中,内个线程都有自己的工作内存,线程的工作内存保存被线程使用到的变量的主内存副本拷贝。线程对变量的所有操作都必须在工作内存中进行,为不能直接读写主内存的变量。不同线程之间也不恩能够直接访问对方工作内存中的变量,线程间比变量值的传递通过主内存来完成。

本文主要是了解并发编程中的涉及一些基础概念,如:临界区、互斥量、CAS、重排序以及 Java 语言中的一些关键字。

临界区

保证在某一时刻只有一个线程能访问数据的简便方法,在任意时刻只允许一个线程对资源进行访问。如果有多个线程试图同时访问临界区,那么在有一个线程进入后,其他所有试图访问临界区的线程将被挂起,并一直持续到进入临界区的线程离开。临界区在被释放后,其他线程可以继续抢占,并以此达到用原子方式操作共享资源的目的

互斥量

互斥量和临界区很相似,只能拥有互斥对象的线程才能具有访问资源的权限,由于互斥对象只有一个,因此就决定了任何情况下次共享资源都不会同时被多个线程所访问。当前占据资源的线程在任务处理完后应将拥有的互斥对象交出,以便其他线程在获得后可以访问资源。互斥量比临界区复杂,因为使用互斥不仅仅能够在同一应用程序不同线程中实现资源的安全共享,而且可以在不同应用程序的线程之间实现对资源的安全共享。

管程/信号量

管程和信号量是同一个概念。指一个互斥独占锁定的对象或称为互斥体。在给定的时间,仅有一个线程可以获得管程。当一个线程需要锁定,他必须进入管程。所有其他的试图进入已经锁定的管程的线程必须挂起直到第一个线程退出管程。这些其他的线程被称为等待线程。一个拥有管程的线程如果愿意的话可以再次进入相同的管程(可重入性)

CAS操作

CAS操作(compare and swap)CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则返回V。这是一种乐观锁的思路,它相信在它修改之前,没有其它线程去修改它;而Synchronized是一种悲观锁,它认为在它修改之前,一定会有其它线程去修改它,悲观锁效率很低。下面来看一下AtomicInteger是如何利用CAS实现原子性操作的。

重排序

编译器和处理器为了提高性能,而在程序执行时会对程序进行重排序。他的出现是为了提高程序的并发度。从而提高性能;但是对于多线程程序,重排序可能会导致程序执行的结果不是我们需要的结果,重排序分为编译器和处理器俩个方面。而处理器重排序包括指令级重排序和内存重排序。

JAVA中线程安全相关关键字及类

主要包括:synchronized,Volitile,ThreadLocal,Lock,Condition

2.1 Volitile

作用:

  • 1)保证了心智能立即存储到主内存才,每次使用前立即从主内存中刷新
  • 2)禁止指令重排序优化

Volitile关键字不能保证在多线程环境下对共享数据的操作的正确性,可以使用在自己状态改变之后需要立即通知所有线程的情况下,只保证可见性,不保证原子性。即通过刷新变量值确保可见性。

Java中synchronized和final也能保证可见性

synchronized:同步快通过变量锁定前必须清空工作内存中的变量值,重新从主内存中读取变量值,解锁前必须把变量值同步回主内存来确保可见性。

final:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把this引用传递进去,那么在其他线程中就能看见final字段的值,无需同步就可以被其他线程正确访问。

2.2 synchronized

把代码块声明为synchronized,有俩个作用,通常是指改代码具有原子性和可见性。如果没有同步机制提供的这种可见性,线程看到的共享比那里可能是修改前的值或不一致的值,这将引发许多严重问题。

原理:当对象获取锁是,他首先是自己的高速缓存无效,这样就可以保证直接从主内存中装入变量,同样在对象释放锁之前,他会刷新其高速缓存,强制使已做的任何更改都出现在主内存中,这样会保证在同一个锁上同步的俩个线程看到在synchronized块内修改的变量的相同值。

synchronized 释放由JVM自己管理。

存在的问题:

  • 1)无法中断一个正在等待获得锁的线程
  • 2)无法通过投票得到锁,如果不想等待下去,也就没法得到锁
  • 3)同步还需要锁的释放只能在与获得锁所在的堆栈帧相同的堆栈中进行,多数情况下,这没问题(而且与一场处理交互的很好),但是,确实存在一些非块结构的锁定更适合情况。

2.3 Lock

Lock是有JAVA编写而成的,在java这个层面是无关JVM实现的。包括:ReentrantLock,ReadWriteLock。其本质都依赖于AbstractQueueSynchronized类。Lock提供了很多锁的方式,尝试锁,中断锁等。释放锁的过程由JAVA开发人员自己管理。

就性能而言,对于资源冲突不多的情况下synchronized更加合理,但如果资源访问冲突多的情况下,synchronized的性能会快速下降,而Lock可以保持平衡。

2.4 condition

Condition将Object监视器方法(wait,notify,notifyall)分解成截然不同的对象,以便通过这些对象与任意Lock实现组合使用,为每个对象提供多个等待set(wait-set),,其中Lock替代了synchronized方法和语句的使用,condition替代了Object监视器方法的使用。Condition实例实质上被你绑定到一个锁上。要为特定Lock实例获得Condition实例,请使用其newCondition()方法。

2.5 ThreadLock

线程局部变量。

变量是同一个,但是每个线程都使用同一个初始值,也就是使用同一个变量的一个新的副本,这种情况下TreadLocal就非常有用。

应用场景:当很多线程需要多次使用同一个对象,并且需要该对象具有相同初始值的时候,最适合使用TreadLocal。

事实上,从本质上讲,就是每个线程都维持一个MAP,而这个map的key就是TreadLocal,而值就是我们set的那个值,每次线程在get的时候,都从自己的变量中取值,既然从自己的变量中取值,那就肯定不存在线程安全的问题。总体来讲,TreadLocal这个变量的状态根本没有发生变化。它仅仅是充当了一个key的角色,另外提供给每一个线程一个初始值。如果允许的话,我们自己就能实现一个这样的功能,只不过恰好JDK就已经帮助我们做了这个事情。

使用TreadLocal维护变量时,TreadLocal为每个使用该变量的线程提供独立地变量副本,所以每一个线程都可以独立地改变自己的副本,而不会英语其他线程所对应的副本。从线程的角度看,目标变量对象是线程的本地变量,这也是类名中Local所需要表达的意思。

TreadLocal的四个方法:

  • void set(Object val),设置当前线程的线程局部变量的值
  • Object get()返回当前线程所对用的线程局部变量。
  • void remove() 将当前线程局部变量的值删除,目的是为了减少内存的占用,线程结束后,局部变量自动被GC
  • Object initValue() 返回该线程局部变量的初始值,使用protected修饰,显然是为了让子类覆盖而设计的。

线程安全的实现方式

3.1 互斥同步

在多线程访问的时候,保证同一时间只有一条线程使用。临界区,互斥量,管程都是同步的一种手段。

java 中最基本的互斥同步手段是synchronized,编译之后会形成monitorenter和monitorexit这俩个字节码指令,这俩个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象,还有一个锁的计数器,来记录加锁的次数,加锁几次就要同样解锁几次才能恢复到无锁状态。

java 的线程是映射到操作系统的原生线程之上的,不管阻塞还是唤醒都需要操作系统的帮助完成,都需要从用户态转换到核心态,这是很耗费时间的,是 java 语言中的一个重量级的操作,虽然虚拟机本身会做一点优化的操作,比如通知操作系统阻塞之前会加一段自旋等待的过程,避免频繁切换到核心态。

3.2 非阻塞同步

互斥和同步最主要的问题就是阻塞和唤醒所带来的性能的问题,所以这通常叫阻塞同步(悲观的并发策略).随着硬件指令集的发展,我们有另外的选择:基于冲突检测的乐观并发策略,通俗讲就是先操作,如果没有其他线程争用共享的数据,操作就成功,如果有,则进行其他的补偿(最常见的就是不断的重试)。这种乐观的并发策略许多实现都不需要把线程先挂起,这种同步操作被称为非阻塞同步。

3.3 无同步

部分代码天生就是线程安全的,不需要同步。

  • 1)可重入代码:纯代码,具有不依赖存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不调用非可重入的方法等特征,它的返回结果是可以预测的。
  • 2)线程本地存储:把共享数据的可见性范围限制在同一个线程之内,这样就无需同步也能保证线程之间不出现数据争用问题。可以通过java.lang.TreadLocal类来实现线程本地存储的功能。
作者

卫恒

发布于

2018-11-10

更新于

2022-04-23

许可协议

评论