一、线程安全的概念
当多个线程访问一个对象时,如果不考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,哪这个对象就是线程安全的。
简单理解,要求代码本身封装了所有必要正确性保障手段(如互斥同步),令调用者无需关心线程问题,更无需采用任何措施来保障多线程的正确使用。
举个例子,对Vector的线程安全的测试:
理论上应该出现数组越界异常,不果并没有出现,可能需要多次测试。
尽管Vector的get(),remove(),size()放法都是同步的,但是在多线程环境中,不做额外的同步措施的话,这段代码依然是不安全的。
我的理解书,在代码里先判断再执行了删除和操作,很有可能在thread2线程获取size()之后,thread1线程删除掉最后一个元素,这样thread2再去获取最后一个元素的时候,就会出现ArrayndexOutOfSountException.
因此这段代码正确是实现方式应该是:
因此可以说,这里的Vector并不是绝对线程安全的,必须加入同步以保证vector访问的线程安全性。
造成线程安全问题的主要诱因有两点,一是存在共享数据,二是存在多条线程共同操作共享数据。
如果一段代码根本不会和其他线程共享数据,那么,从线程安全的角度来讲的话,那么程序是串行的还是多线程执行的完全没有区别
线程安全程度来说,可以把操作共享的数据分成下面5类:(由强到弱)
1.不可变
2.绝对线程安全
3.相对线程安全
4线程兼容
5.线程对立
二、线程安全的实现方式
1.互斥同步
是一种常见的并发性保障手段
互斥和同步的区别:
同步:指在多个线程并发访问共享数据时,保证共享数据在统一时刻只被一个(或者是一些,使用信号量的时候)线程使用。
互斥:是实现同步的一种手段,临界区,互斥量,信号量都是主要的互斥实现方式。
互斥是因,同步是果,互斥是方法,同步是目的。
1)sychronized
基本含义及使用方式
最基本的同步方式,有三种用法:
a.修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
分析:由于i++不具备原子性(具体原因不在本节讨论),依靠sychronized保证线程安全。否则很可能看到小于2000000的结果。
如果获取的锁不是同一个对象,而是两个不同的对象,会出现两种情形:
1)两个线程操作的不是共享数据,那么线程安全有保障
2)两个线程操作的是共享数据,那么可能就会出现下面的问题:
该代码有严重线程安全问题,两个线程虽然都会进入同步方法,但是却分别获取不了不同对象锁,而且操作的是共享数据i。避免这种情况是使用修饰静态方法的方式加锁,因为该类对象只有一个,对象锁唯一,因此可以使用这种方式保护静态成员。
b.修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
锁住的是当前class对象,线程a调用静态sychronized方法和线程b调用非静态sychronized方法,是允许的。因为占用的锁对象不同,因此,如果修饰其他线程调用increase4Obj的话,就可能出现线程安全问题。
c.修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁
作用:方法提较长,提高性能。
三种方式,实例对象,this对象,class对象
了解完synchronized的基本含义及其使用方式后,下面我们将进一步深入理解synchronized的底层实现原理。
synchronized底层语义原理
说明:a、b是方法级别的同步,方法级的同步是隐式,无需通过字节码来控制
c是代码块的同步,同步一段指令集序列通常是由Java 语言中的synchronized 块来表示的,Java 虚拟机的 指令集中有monitorenter 和monitorexit 两条指令来支持synchronized 关键字的语义,可以使用javap进行查看字节码指令:
java虚拟机规范对于该指令的解释如下:
总结:
1.sychronized锁的是对象关联的monitor
2.sychronized是可重入锁
比如线程 A、B。对象 Foo 有同步方法 M、N。线程 A 首先执行同步方法 M 时就会获取对象锁,此时 B 不能执行同一把对象锁修饰的方法 M、N,但可以访问其他没有非sychroniaze方法,除非 A 释放锁。
又因为锁是可重入的,所以 A 可以继续执行 M,N 方法。可重入锁一定程度上避免了死锁的问题,内部是关联一个计数器,加一次锁计数器值加一,为零时释放锁。
如何理解锁是对象?这需要了解jvm对于锁的实现,以及对象的结构。
每个 Java Object 在 JVM 内部都有一个 native 的 C++ 对象 oop/oopDesc 与之对应,回顾一下 oopDesc 的类定义(内存布局)
以下是对象在heap中的内存布局:
Mark Word:部分数据的长度在32位和64位虚拟机(未开启压缩指针)中分别为32bit和64bit。然后对象需要存储的运行时数据其实已经超过了32位、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的外存储成本,Mark Word一般被设计为非固定的数据结构,以便存储更多的数据信息和复用自己的存储空间。类型指针指向它的类元数据的指针,用于判断对象属于哪个类的实例。
实例数据:存储的是真正有效数据,如各种字段内容,各字段的分配策略为longs/doubles、ints、shorts/chars、bytes/boolean、oops(ordinary object pointers),相同宽度的字段总是被分配到一起,便于之后取数据。父类定义的变量会出现在子类前面。
对齐填充:部分仅仅起到占位符的作用,并非必须。
其中轻量级锁和偏向锁是jdk1.6对sychronized进行优化后新增的。后面我们在讲锁优化的时候在进行分析。
其中重量锁就是操作系统的互斥锁来实现的。
为什么引入这么多种类的锁,原因是为了某些情况下没有必要加重量级别的锁,如没有多线程竞争,减少传统的重量级锁产生的性能消耗。
现在我们分析以下sychronized也就是传统重量级锁的实现方式,也就是锁标志为10,其中指针指向的是Monitor(管程或监视器锁)对象的的其实地址。每个对象都存在一个monitor与之关联,对象与其monitor有多种关联方式,不同虚拟机有不同的实现:
1)monitor与对象一起创建销毁
2)当线程试图获取对象锁时自动生成
monitor是由ObjectMonitor实现的,其主要的数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)
其中需要注意的是_EntryList,_WaitSet,和_owener
上图简单描述了这个过程
a._EntryList和_WaitSet用来保存ObjectWaiter对象列表(每个等待锁的对象都会被封装成ObjectWaiter对象),_owener指向持有ObjectMonitor对象的线程。
(_EntryList和_WaitSet里的所有线程都处于阻塞状态,(阻塞在linux下通过pthread_mutux_lock函数)线程阻塞后便进入内核状态调度状态,这个会导致系统在用户态和内核态来回切换,严重影响性能->自旋锁)
b.当多个线程访问一段同步代码的时候,首先会进入_EntryList集合
c.当线程获取到对象monior后进入_owner区域并把monitor中的owner变量设置为当前线程,并且把当前monitor的计数器count加1
d.若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。
e.若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor
jvm找到的更加详细的描述:
由此看来,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),,synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因
上图其实是 Java 线程运行状态的一个简单版本,看下线程执行状态图:
一个常见的问题是 wait()、sleep()、yield() 方法的区别是什么?wait() 和 sleep()、yield() 最大的不同在于 wait() 会释放对象锁,而 sleep()、yield() 不会,sleep() 是让当前线程休眠,而 yield() 是让出当前 CPU。
那么问题来了?哪里体现重量级了呢?
重量级:java线程是映射到操作系统的原生线程上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙,这就需要从用户态转到和核心态中,因此,状态转换,需要耗费很多的处理器时间。对于简单的getter和setter方法来说,状态转换的时间可能比代码执行的时间还要长。
产生原因:
1)系统调用引起的内核态与用户态切换
2)线程阻塞造成的线程切换
问题:阻塞会进入到上面所说的队列中吗?
轻量级锁的实现中,出现竞争情况,会膨胀为重量级锁。没有锁竞争的情况,重量级锁monitor还会出现阻塞的情况吗?
个人理解:
轻量级锁出现竞争的情况是,在进行CAS操作将对象头中的MarkWord设置为指向LockRecord的指针的时候,如果这个操作失败了,说明有其他的线程已经获取到该对象的锁。此时膨胀为重量级锁。
使用轻量级锁的情况下,能避免直接使用操作系统互斥量带来的系统调用带来的开销。互斥量涉及到内核的系统调用权限,需要进程在内核态和用户态之间转换,
有时间再详细探讨。
2) Lock
jdk1.5之前只有voletile,1.5之后增加了ReentrantLock
作用:不是替代内置锁,而是内置锁机制不使用时,作为一种可以选择的高级功能。
更Sychronized的不同点:Lock提供了一种无条件的、轮询的、定时的以及可中断的锁获取操作,所有的加锁和解锁的方法都是显式的,多种获取锁模式,不可用性问题提供了更好的灵活性
相同点:内存可见性,互斥性,可重入
为什么要有这样类似的锁机制?
内置锁的局限性:无法中断一个正在等待获取锁的线程;无法在请求获取一个锁时无限等待下
基本例子
轮询锁和定时锁
可中断的锁获取操作
非结构的加锁
2.非阻塞同步
互斥同步的问题:线程阻塞和唤醒带来的性能问题(进程在用户态到内核态切换的性能问题)。
这是一种悲观的并发策略:总是认为只要不去做正确的同步措施,那就肯定会出现问题,无论共享数据是否会出现竞争,都要进行加锁、用户态核心态转换、维护锁计数器和检查是否线程需要唤醒等操作。
乐观的并发策略:基于冲突检测的乐观并发策略,通俗的说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,就采用其他的补偿措施(常见的比如不断重试,直到成功),这种乐观的并发策略许多实现都不需要把线程挂起,因此称为非阻塞同步。
产生条件:硬件指令集的发展。需要保证操作和冲突检测两个步骤具备原子性,考什么来保证呢?如果再使用操作系统互斥量就没意义了,所以只能靠硬件来保证
硬件指令集使看起来需要多次操作的行为通过一条处理器指令就能完成,这类指令:
1.测试并设置(Test-and-Set)
2.获取并增加(Fetch-and-Inceament)
3.交换(swap)
4.比较并交换(Compare-and-Swap,CAS)
5.加载链接/条件存储(Load-Linked/Store-Conditional,LL/SC)
CAS指令需要3个操作数,分别是内存位置,旧的预期值,和新值。CAS指令执行时,当且仅当v符合预期的旧值时,处理器用新值更新v的值,否则不更新,无论更新是否成功,都返回旧值-原子操作
JDK1.5后才可以使用CAS操作,UnSafe.getUnsafe中的代码中限制了只有启动类加载器才能访问她,因此,如果不采用反射,只能通过其他的java api来间接使用它,比如J.U.C包里的整数原子类,其中的conpareAndSet()和getAndIncreament()等方法都使用了Unsafe类的CAS操作,一些情况下,无须再使用互斥同步,举个例子:
逻辑漏洞:ABA问题
解决:带有标记的原子引用类:AtomicStampedReferrence,控制变量值的版本保证CAS正确性
不果大多数情况不使用,ABA问题一般不会影响并发的正确行性,解决这种情况,建议互斥同步。
3.无同步方案
1.可重入代码
参考幂等性
2.线程本地存储
ThreadLocal