由Synchronized引发的一系列问题


说起线程安全,那就不得不提到加锁,说起加锁呢,自然而然就会浮现出synchronized这个关键字。而对于Synchronized,又了解多少呢?

1.Synchronized 的用法

Synchronized是Java提供的一个并发控制的关键字。主要有两种用法:同步方法和同步代码块。

  • 修饰实例方法,对当前实例对象this加锁(下面两种写法是等价的)
	public class Synchronized {
		public synchronized void test(){
		  // 逻辑操作
		}	
    }
	public class Synchronized {
		public void test() {
		    synchronized(this) { 
		     // 逻辑操作
		    }
		}
	}
  • 修饰静态方法,对当前类的Class对象加锁 (下面两种写法是等价的)
	public synchronized static void test(int n) {
	    // 逻辑操作
	}
	public class Synchronized {
	  public void test(){
		  synchronized(Synchronized.class){
		   // 逻辑操作
		  } 
	  }
	}            
  • 修饰代码块,指定一个加锁的对象,给对象加锁
  public class Synchronized {
	  public void test(){
		synchronized(new Test()){
		
		}
     }
 }

JVM对于同步方法和同步代码块的处理方式不同。

2.synchronized 锁的实现

对于同步方法,JVM采用ACC_SYNCHRONIZED标记符来实现同步。

当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED,如果有设置,则需要先获得监视器锁,然后开始执行方法,方法执行之后再释放监视器锁。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。

对于同步代码块。JVM采用monitorenter、monitorexit两个指令来实现同步。

可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁。 每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter)后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁(执行monitorexit指令)的时候,计数器再自减。当计数器为0的时候。锁将被释放,其他线程便可以获得锁。

无论是ACC_SYNCHRONIZED还是monitorenter、monitorexit都是基于Monitor实现的,在Java虚拟机(HotSpot)中,Monitor是基于C++实现的,由ObjectMonitor实现。

ObjectMonitor类中提供了几个方法,如enter、exit、wait、notify、notifyAll等。sychronized加锁的时候,会调用objectMonitor的enter方法,解锁的时候会调用exit方法。

每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针

Monitor 结构如下:
在这里插入图片描述

  • 刚开始 Monitor 中 Owner 为 null
  • 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一 个 Owner
  • 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入 EntryList BLOCKED
  • Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的
  • 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程。

引入:
在 JVM 中,对象在内存中分为三块区域:

1、对象头

  • Mark Word(标记字段):默认存储对象的HashCode,锁信息或分代年龄或GC标志等信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
    在这里插入图片描述
  • hash:保存对象的哈希码
  • age:保存对象的分代年龄
  • biased_lock:偏向锁标识位
  • lock:锁状态标识位
  • JavaThread*:保存持有偏向锁的线程ID
  • epoch:保存偏向时间戳
  • Klass Point(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

2、实例数据

  • 这部分主要是存放类的数据信息,父类的信息。

3、对齐填充

  • 由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。

3.synchronized的特性

3.1 有序性

有序性即程序执行的顺序按照代码的先后顺序执行

除了引入了时间片以外,由于处理器优化和指令重排等,CPU会为了优化我们的代码,会对我们程序进行重排序。

synchronized是无法禁止指令重排和处理器优化的。但synchronized还是保证了有序性,这和as-if-serial语义有关

as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。有数据依赖的也是不能重排序的。

由于synchronized修饰的代码,同一时间只能被同一线程访问。那么也就是单线程执行的。所以,可以保证其有序性。

3.2 可见性

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。所以,就可能出现线程1改了某个变量的值,但是线程2不可见的情况。

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

synchronized和volatile都具有可见性,其中synchronized对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到主存当中,保证资源变量的可见性,如果某个线程占用了该锁,其他线程就必须在锁池中等待锁的释放。

与volatile的实现类似,被volatile修饰的变量,每当值需要修改时都会立即更新主存,主存是共享的,所有线程可见,所以确保了其他线程读取到的变量永远是最新值,保证可见性。

3.3 原子性

线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权。所以在多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。(具体算法详见操作系统的时间片轮转调度算法)

原子性是指一个操作是不可中断的,要全部执行完成,要不就都不执行。
为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit前面中,介绍过,这两个字节码指令,在Java中对应的关键字就是synchronized。

通过monitorenter和monitorexit指令,可以保证被synchronized修饰的代码在同一时间只能被一个线程访问,在锁未释放之前,无法被其他线程访问到。因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。

3.4 可重入性

可重入锁,也叫做递归锁,指的是同一线程 外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。

Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。

通俗一点讲就是说一个线程拥有了锁仍然还可以重复申请锁。

3.5 不可中断性

不可中断就是指,一个线程获取锁之后,另外一个线程处于阻塞或者等待状态,前一个不释放,后一个也一直会阻塞或者等待,不可以被中断。

4.synchronized 锁优化

JDK6 以前,synchronized 属于重量级锁,每次加锁都依赖操作系统Mutex Lock实现,涉及到操作系统让线程从用户态切换到内核态,切换成本很高。为了减少获得锁和释放锁所带来的性能消耗,JDK6里引入了“偏向锁”和“轻量级锁”,通过锁消除、锁粗化、自旋锁等方法使用各种场景,给synchronized性能带来了很大的提升。

4.1 锁膨胀

在JDK6 里锁一共有四种状态,无锁状态(unlocked)、偏向锁状态(biasble)、轻量级锁状态(lightweight locked)和重量级锁状态(inflated),它会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。

synchronized锁升级:偏向锁 → 轻量级锁 → 重量级锁

4.1.1 偏向锁

初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。存在的依据是大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得

当锁就进入偏向模式时,Mark Word的结构也就变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查Mark Word的锁标记位为偏向锁以及当前线程ID等于Mark Word的ThreadID即可。

偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。
在这里插入图片描述

4.1.2 轻量级锁

轻量级锁是由偏向锁升级而来,一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)

锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。

轻量级锁加锁:线程在执行同步块之前,JVM 会先在当前线程的栈桢中创建用于存储 锁记录(Lock Record) 的空间,并将对象头中的 Mark Word 复制到锁记录中,官方称为 Displaced Mark Word。然后把锁记录中的Owner指向加锁的对象(存放对象地址),线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果成功,改变锁标志位,当前线程获得锁。如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

自旋:即不停地循环判断锁是否能够被成功获取。

  • 长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)
  • 允许短时间的忙等现象。短时间的忙等,换取线程在用户态和内核态之间切换的开销。

轻量级锁解锁:轻量级解锁时,会使用原子的 CAS 操作来将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

4.1.3 重量级锁

当锁升级为轻量级锁之后,如果依然有新线程过来竞争锁,首先新线程会自旋尝试获取锁,尝试到一定次数(默认10次)依然没有拿到,锁就会升级成重量级锁。

自旋锁的默认大小是10次,-XX:PreBlockSpin可以修改

4.2 锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,在JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。
在这里插入图片描述

4.3 锁粗化

锁粗化是虚拟机对另一种极端情况的优化处理,通过扩大锁的范围,避免反复加锁和释放锁。
在这里插入图片描述

4.4 自旋锁与自适应自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。

自旋锁:许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得,通过让线程执行循环等待锁的释放,不让出CPU。如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式。但是它也存在缺点:如果锁被其他线程长时间占用,一直不释放CPU,会带来许多的性能开销。

自适应自旋锁:这种相当于是对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点。

5.总结

在这里插入图片描述

6.扩展

6.1 Monitor

无论是同步方法还是同步代码块,无论是ACC_SYNCHRONIZED还是monitorenter、monitorexit都是基于Monitor实现的.

管程 (英语:Monitors,也称为监视器) 是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。这些共享资源一般是硬件设备或一群变量。管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。与那些通过修改数据结构实现互斥访问的并发程序设计相比,管程实现很大程度上简化了程序设计。 管程提供了一种机制,线程可以临时放弃互斥访问,等待某些条件得到满足后,重新获得执行权恢复它的互斥访问。

Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。

在Java虚拟机中,Monitor是基于C++实现的,由ObjectMonitor实现的,其主要数据结构如下:

ObjectMonitor::ObjectMonitor() {  
  _header       = NULL;  
  _count       = 0;  
  _waiters      = 0, 
   //线程的重入次数 
   _recursions   = 0;      
   _object       = NULL;
  //标识拥有该monitor的线程  
   _owner        = NULL;   
  //等待线程组成的双向循环链表,_WaitSet是第一个节点
   _WaitSet      = NULL;    
   _WaitSetLock  = 0 ;  
   _Responsible  = NULL ;  
   _succ         = NULL ; 
    //多线程竞争锁进入时的单向链表 
   _cxq          = NULL ;  
   FreeNext      = NULL ; 
  //_owner从该双向循环链表中唤醒线程结点,_EntryList是第一个节点 
   _EntryList    = NULL ;    
   _SpinFreq     = 0 ;  
   _SpinClock    = 0 ;  
   OwnerIsThread = 0 ;      
}  

当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,
当某个线程获取到对象的monitor后进入_Owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count加1。即获得对象锁。
若持有monitor的线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null,_count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
在这里插入图片描述

加锁的时候,会调用objectMonitor的enter方法,解锁的时候会调用exit方法。事实上,只有在JDK1.6之前,synchronized的实现才会直接调用ObjectMonitor的enter和exit,这种锁被称之为重量级锁。

6.2 CAS

CAS ,Compare And Swap ,即比较并交换。整个 AQS 同步组件、Atomic 原子类操作等等都是基于CAS 实现的,甚至 ConcurrentHashMap 在 JDK 1.8 的版本中,也调整为 CAS + synchronized 。可以说,CAS 是整个 J.U.C 的基石。

它主要包含三个参数:

/**
*V主内存中的值
*E表示线程中旧的预期值
*N表示新值
**/
CAS(V,E,N)

简单流程分析:比较 V 与 E 是否相等。(比较)----> 如果比较相等,将 N 写入 V。(交换)—> 返回操作是否成功。
在这里插入图片描述

当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。可见 CAS 其实是一个乐观锁。

CAS在java中的应用,主要体现在java.util.concurrent.atomic包下的类(Atomic系列)。跟随该包中的类的代码我们一路往下,就能发现最终调用的是 sum.misc.Unsafe 这个类在java中的实现是Unsafe类,类如其名,不安全,因为Unsafe类提供了直接操作内存的方式。

其中Unsafe的compareAndSwapInt 是 Native 的方法。查阅资料和源码,它的核心代码 Atomic::cmpxchg

inline jint Atomic::cmpxchg  (jint  exchange_value, 
                              volatile jint*   dest, 
                              jint     compare_value,
                             cmpxchg_memory_order  order) {
  int mp = os::is_MP();
  __asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
                    : "=a" (exchange_value)
                    : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
                    : "cc", "memory");
  return exchange_value;
}
  • __ asm __ 的意思是这个是一段内嵌汇编代码。也就是在 C 语言中使用汇编代码。
  • 这里的 volatile和 JAVA 有一点类似,但不是为了内存的可见性,而是告诉编译器对访问该变量的代码就不再进行优化。
  • LOCK_IF_MP(%4) 的意思就比较简单,就是如果操作系统是多核的,那就增加一个 LOCK。
  • cmpxchgl 就是汇编版的“比较并交换”。但是我们知道比较并交换,有三个步骤,不是原子的。所以在多核情况下加一个 LOCK,由CPU硬件保证他的原子性。
  • LOCK在的早期实现是直接将 cup 的总线阻塞,这样的实现可见效率是很低下的。后来优化为X86 cpu 有锁定一个特定内存地址的能力,当这个特定内存地址被锁定后,它就可以阻止其他的系统总线读取或修改这个内存地址。

JAVA 的 cas 是怎么实现的:

  • java 的 cas 利用的的是 unsafe 这个类提供的 cas 操作。
  • unsafe 的cas 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg
  • Atomic::cmpxchg 的实现使用了汇编的 cas 操作,并使用 cpu 硬件提供的 lock信号保证其原子性

CAS的ABA问题
CAS 由三个步骤组成,分别是“读取->比较->写回”。
线程1和线程2同时执行 CAS 逻辑,两个线程的执行顺序如下:

时刻1:线程1执行读取操作,获取原值 A,然后线程被切换走
时刻2:线程2执行完成 CAS 操作将原值由 A 修改为 B
时刻3:线程2再次执行 CAS 操作,并将原值由 B 修改为 A
时刻4:线程1恢复运行,将比较值(compareValue)与原值(oldValue)进行比较,发现两个值相等。
然后用新值(newValue)写入内存中,完成 CAS 操作

如上流程,线程1并不知道原值已经被修改过了,在它看来并没什么变化,所以它会继续往下执行流程。

ABA问题的解决办法

1.在变量前面追加版本号:每次变量更新就把版本号加1,则A-B-A就变成1A-2B-3A。
2.atomic包下的AtomicStampedReference类:其compareAndSet方法首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用的该标志的值设置为给定的更新值。

CAS 的应用

1.Java的concurrent包下就有很多类似的实现类如Atomic开头那些。
2.自旋锁
3.令牌桶限流器

令牌桶限流器
就是系统以恒定的速度向桶内增加令牌。每次请求前从令牌桶里面获取令牌。如果获取到令牌就才可以进行访问。当令牌桶内没有令牌的时候,拒绝提供服务。我们来看看 eureka 的限流器是如何使用 CAS 来维护多线程环境下对 token 的增加和分发的。

CAS的缺点:

1.循环时间长CPU开销较大
在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。

2.只能保证一个共享变量的原子操作
CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。

7.参考资料

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值