我理解的Java并发基础(三):线程安全与锁

Java线程的状态
新建(New)、运行(Runable)、无限期等待(Waiting)、限期等待(TimedWaiting)、阻塞(Blocked)、结束(End)。

网上找到的一份线程的状态图

  无限期等待: 线程不会被分配CPU执行时间,等待其他线程显式的唤醒。比如:Object.wait()、Thread.join()等。
  限期等待: 线程也不会被分配CPU执行时间,一定时间后由操作系统唤醒。比如:Thread.sleep(long timeout)、Object.wait(long timeout)、Thread.join(long millis)。
  阻塞: 等待获取排它锁的状态。

  线程的优先级高是告诉操作系统的调度器以较高的频率执行线程。jvm有10个层级的优先级,windows系统有7个层级,linux系统有100个层级,而Sun的Solaris更是具有2的31次方个层级。jvm与操作系统之间的线程优先级映射不能很好的匹配。建议调整优先级的时候选用MAX_PRIORITY、NORM_PRIORITY和MIN_PRIORITY三种级别。
  线程的优先级默认是5。

不同线程之间对线程状态进行通信的Api:

  • yield() 可以用来告诉调度器,该线程已经执行完了最重要的部分,可以适当让出CPU了。
  • join() 本线程进入等待状态,直到被join()的线程执行完毕后再继续执行。JDK1.7有fork/join框架。
  • sleep()和yield()都没有释放锁。wait()会释放锁。
  • wait()、sleep()、notify()、notifyAll()可以用来完成线程之间的协作。
  • interrupt()方法可以中断被阻塞的任务。推荐使用Executor的shutdownNow()来中断它启动的所有线程。

Thread.sleep(0)、Thread.sleep(1)、Thread.yeld()的区别

  • Thread.sleep(1):普通的sleep()方法。释放CPU使用权并睡眠1毫秒,之后继续争抢CPU使用权
  • Thread.yeld():释放当前的CPU使用权,有调度器安排其他线程使用CPU。如果当前没有线程争抢CPU,则该线程继续执行。
  • Thread.sleep(0):释放当前CPU使用权,但是,调度器只能安排优先级比当前线程高的线程来使用CPU。如果没有比当前线程优先级高的,则当前线程继续执行。

什么是线程安全问题?
  多线程执行对共享变量进行操作,操作的结果符合程序员的预期,则线程安全。如果操作结果随机且不符合程序员预期则线程不安全。

备注
  通常说的线程安全指的是某一个方法或操作是否是线程安全的。当多个方法并行执行的时候,就另说了: 线程绝对安全
  某方法一定是线程安全的,即使与别的方法并行操作同一个资源。比如CopyOnWriteArrayList、AtomicReference<V>、ReentrantReadWriteLock等。
线程相对安全
  比如Vector、HashTable等这类资源,通常都是某个单一操作是线程安全的,但当多个该类对象的方法并行的时候就可能出错,比如在A在遍历,而B在A遍历期间对元素进行了删除。

线程安全的实现方法:
1,互斥同步(阻塞式、悲观锁)。
  实现互斥同步的方式:临界区(CriticalSection)、互斥量(Mutex)、信号量(Semaphore)
  Java中最基本的互斥同步手段的关键字是synchronized。synchronized编译后的字节码在同步块前后会形成monitorenter和monitorexit两个字节码指令。

2,非阻塞同步(乐观锁)。
  概念:通俗讲,先进行操作,如果期间没有其他线程争抢共享数据,就操作成功了。整个过程没有锁操作。但如果操作期间发现有其他线程在同时操作并产生了冲突,那就采取其他的补偿措施(最常见的补偿措施就是不断的尝试,知道成功为止)。这种策略并不会把线程挂起,所以叫做非阻塞同步。
  这种策略由CPU提供的CAS(Compare-and-Swap)的原子性操作来保障。

Compare-and-Swap(CAS):比较和交换。
  cpu拿到原始值和地址值进行运算,得到计算后的新值。然后拿着原始值和地址值去内存中找到值进行比较,如果相同,就表示没有其他线程有共享操作,用它计算后的新值替换掉内存中的原始值。如果不同,就拿着第二次从内存中的值再进行一次运算,重复同样的动作,直到成功。
  只比较值的CAS会有一个ABA的问题。主内容中的值为A,线程1拿走去运算了,线程2也拿走去运算了。线程2将新值B设置成功,其他线程拿到B后又设置回了A。这时候线程1拿着运算后的值C来比较了,因为只比较值,所以线程1会认为这个值并没有发生变化因为设置成C。因而发生线程安全问题。
  解决办法是拿着内存值的版本号来进行对比。

volatile

  被volatile修饰的变量,对其他线程具有“可见性”。
  被volatile修饰的变量,生成的汇编指令,在该变量前会有Lock指令。Lock前缀的指令在多核处理器下会引发两件事情:

  1. 将当前处理器缓存行的数据写会到到系统内存
  2. 这个写会内存的操作回事其他CPU里缓存了该内存地址的数据无效

java线程之间的通信有4种方式:

  1. A线程写volatile变量, 随后B线程读这个volatile变量。
  2. A线程写volatile变量, 随后B线程用CAS更新这个volatile变量。
  3. A线程用CAS更新一个volatile变量, 随后B线程用CAS更新这个volatile变量。
  4. A线程用CAS更新一个volatile变量, 随后B线程读这个volatile变量。

concurrent包的实现:AtomicXxx类的加减和赋值操作。

关于volatile的原理以及cpu对Lock前缀指令实现可见性的原理参考http://www.cnblogs.com/xrq730/p/7048693.html

synchronized

java中的每一个对象都可以作为锁,有以下3种表现形式:

  1. synchronized修饰普通方法,锁对象是当前实例对象
  2. synchronized修饰静态方法,锁对象是当前类的class对象
  3. synchronized作用于同步方法块,锁对象是synchronized()括号里的对象

  synchronized代码块同步指令是monitorenter和monitorexit指令实现的。需要同步的代码前使用monitorenter,同步结束后的代码块后使用monitorexit。monitorenter和monitorexit是必须成对出现的。虚拟机执行monitorenter指令时,会去获取对应的对象的锁,执行monitorexit指令时会还回对应的对象的锁。

  java.util.concurrent.locks包中有ReentrantLock类,也是并发同步时经常使用的。

ReentrantLock与synchronized的区别
  synchronized是一个java的关键字,表示同步。而ReentrantLock是java的一个class。二者的却别主要体现在ReentrantLock的一些API上:

  1. 构造方法可以选择是否执行公平锁,默认非公平锁;
  2. 尝试获取锁(可以轮询) tryLock()和尝试一段时间内去获取锁tryLock(long timeout, TimeUnit unit);
  3. 锁中断lockInterruptibly(),死锁的时候可以采用锁中断自己来解除死锁;
  4. 一个ReentrantLock对象可以执行newCondition()绑定多个Condition对象来表示多条件执行;
  5. lock()与unlock()允许在不同的方法体中调用,增加使用的灵活性,虽然极不推荐这样使用;

锁优化
  锁优化主要是编译器或者JVM的的优化,跟开发者没有太大的关系,但作为一名合格的开发者,还是需要了解的。锁优化的几种方式:自旋锁与自适应自旋、锁消除、锁粗化、轻量级锁、偏向锁

自旋锁
  互斥同步的性能消耗体现在线程阻塞时的线程挂起与恢复很消耗性能,Java虚拟机开发团队注意到在很多应用上锁的状态只会持续很多时间,为了这很短的时间而阻塞线程不是很划算。于是规定,在线程未获取到锁的时候,不是直接挂起,还是执行一个忙循环(类似while(true))后看是否能获取到锁。这项技术就叫自旋锁。缺点是如果锁占据的时间比较长的时候,白白浪费了自旋时占用的资源。默认自旋次数是10次。
自适应自旋
  虚拟机获取锁的自旋时间不再固定,而是由虚拟机根据前一次的自旋时间及拥有者的状态来决定。换言之,虚拟机运行时间越长越“聪明”。

锁消除
  虚拟机在运行期的优化。如果虚拟机判断到锁内的变量不会出现共享数据,则会将锁进行消除。

锁粗化
  如果虚拟机探测到有一组零碎的操作都对同一个对象加锁,将会把加锁范围扩展(粗化)到整个操作序列的外部,这样只需要加锁一次就可以了。

前面的文章已经介绍过对象在JVM中的布局方式:
  每一个堆中的对象在HosPost虚拟机中都有一个对象头(ObjectHeader),对象头里存储两部分信息:一部分是运行时对象自身的哈希码值(HashCode)、GC分代年龄(Generational GC Age)、锁标志位(占用2个bit的位置)等信息,另一部分是指向方法去中的类型信息的指针。如果是数组对象的话,还会有额外一部分用来存储数组长度。

  当一个线程想获取对象的锁的时候,先读取对象头的锁标志位信息。如果锁标志位是01,表示该对象上没锁。于是会通过CAS来判断是否可以获取锁。获取成功后将锁标志位置为00,表示轻量级锁。如果CAS失败则表示已经有线程在竞争到锁了,则锁标志位置为10,升级为重量级锁,该线程进入阻塞状态。如果线程在获取对象头的锁标志位已经是轻量级锁了,则判断对象锁是否属于当前线程,如果属于则锁重入;如果不属于,则锁标志位置为10,直接升级为重量级锁,线程进入阻塞状态。如果线程在获取对象头的锁标志位已经是重量级锁了,则线程直接进入阻塞状态。当持有锁的线程最终释放锁的时候,如果锁的标志位为00(轻量级锁)则表示在此期间只有自己获取了锁,没有线程竞争。如果锁的标志位是10(重量级锁)则表示有线程曾经竞争过锁,则在释放锁的时候唤醒被挂起的线程。

  轻量级锁提升性能的依据是“绝大部分的锁,在同步期内没有其他线程竞争”,这个是经验数据。轻量级锁的本意是在多线程竞争如果存在锁竞争,除了锁本身互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

偏向锁
  Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个对象首次被一个线程使用CAS请求锁的时候,将对象的锁标志位置为01,偏向锁标志位置为1。以后该线程每次请求该对象锁的时候,连CAS操作都省略掉,直接消除同步来执行。如果有其他线程竞争该对象锁的时候,偏向锁撤销。根据之前的偏向线程是否在执行原本应该是同步的操作的状态来判断升级为轻量级锁还是重量级锁。 偏向锁可以提高带有同步但无竞争的程序性能。如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。在具体情形分析下,禁止偏向锁优反而可能提升性能。

  关于锁机制及其原理,参考http://www.infoq.com/cn/articles/java-se-16-synchronized/

避免死锁的几个常用方法:

  1. 避免一个线程同时获取多个锁
  2. 避免一个线程在锁内同时占用多个资源,尽量保障每个锁只要用一个资源
  3. 尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制
  4. 对于数据库锁,加锁和解锁必须在同一个数据库连接里,否则会出现解锁失败的情况

  当死锁不可避免的时候,一般采用鸵鸟策略。

公平性锁与非公平锁
  公平性锁保证了锁的获取按照FIFO原则,而代价是进行大量的线程切换。非公平性锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量。

参考资料:

猜你喜欢

转载自my.oschina.net/u/3466682/blog/1623661