java线程(二)之线程同步

线程同步

  1. 线程安全问题产生的前提是多个线程并发访问共享资源(如果对资源的访问顺序敏感,就称存在竞态条件),为了防止共享资源可能出现错误或者数据不一致,引入了临界区概念(导致竞态条件发生的代码区称作临界区)。临界区是一个用以访问共享资源的代码块,这个代码块在同一个时间内只允许一个线程执行。如果在等待进入临界区的线程不止一个,JVM会选择其中一个,其余的将继续等待
  2. 一个线程在访问共享数据前必须申请相应的锁(acquire),锁持有的线程可以对该锁保护的共享数据进行访问,访问结束后该线程必须释放(release)相应的锁。锁的持有线程在其获得锁之后和锁释放之前这段时间内所执行的代码被称为临界区
  3. 在同一个程序中运行多个线程本身不会导致问题,问题在于多个线程访问了相同的资源。如,同一内存区(变量、数组或对象)、系统(数据库、web services等)或文件。实际上,这些问题只有在一或多个线程向这些资源做了写操作时才有可能发生,只要资源没有发生变化,多个线程读取相同的资源就是安全的
  4. 按照JVM对锁的实现方式划分
    • 内部锁(synchronized关键字实现):线程对内部锁的申请和释放的动作由JVM负责代为实施
    • 显示锁(Lock接口实现)

锁的特性

  1. 可重入性: 一个线程在其持有一个锁的时候能否再次申请该锁。如果一个线程持有一个锁的时候还能继续成功申请该锁,那么就称该锁是可重入的,否则称该锁为非可重入的

        伪代码:method1未释放Lock,method2又申请Lock,而此时method2能否申请成功?
        method1(){
            1.申请锁Lock
            2.调用method2
            3.释放锁Lock
        }
        
        method2(){
            1.申请锁Lock
            2.执行代码
            3.释放锁Lock
        }
    
  2. 可重入锁是如何实现的?

    • 可重入锁可以理解为一个对象,该对象包含一个计数器属性。计数器属性的初始值为0,表示相应的锁还没有被任何线程持有,每次线程获得一个可重入锁的时候,该锁的计数器值会被增加1。每次一个线程释放锁的时候,计数器属性就会减1。一个可重入锁的持有线程初次获得该锁时相应的开销相对大,这是因为该锁的持有线程必须与其他线程"竞争"才能获得锁,可重入锁的持有线程继续获得相应锁的开销要小的多,因为JVM只需要将相应的计数器属性增加1即可实现锁的获取。
  3. 公平性:java中锁的调度策略也包括公平策略和非公平策略,相应的锁被称为公平锁和非公平锁。内部锁属于非公平锁,而显示锁既支持公平锁又支持非公平锁。

  4. 锁的粒度: 一个锁实例可以保护一个或者多个共享数据。一个锁实例锁保护的共享数据的数量大小就被称为锁的粒度。一个锁实例保护的共享数据的数量大,就称该锁的粒度粗,否则就称该锁的粒度细。锁的粒度过粗会导致线程在申请锁的时候需要进行不必要的等待,锁的粒度过细会增加锁调度的开销。

  5. 锁的开销:锁的开销包括锁的申请和释放所产生的开销,以及锁可能的导致上下文切换,这些开销主要是处理时间。

  6. 死锁:当两个或者多个线程被阻塞并且他们在等待的锁永远不会被释放时,就会发生死锁
    线程A获取锁X,线程B获取锁Y,现在线程A试图获取锁Y,同时线程B也试图获取锁X,则两个线程都将被阻塞,而且他们等待的锁永远不会被释放。(两个线程都试图获取对方拥有的锁)

synchronized

  1. 如果一个对象已经被synchronized声明,则只有一个执行线程被允许访问它。如果其他某个线程试图访问这个对象的其他方法,它将被挂起,直到第一个线程执行完正在运行的方法。

  2. 静态方法有不同的行为,用synchronized关键字声明的静态方法,同时只能被一个执行线程访问,但是其他线程可以访问这个对象的非静态方法。如果两个线程同时访问一个对象的两个不同的synchronized方法(一个静态一个非静态),如果两个方法都改变了相同的数据,将会出现数据不一致的错误

  3. synchronized保证了在并发程序中对共享数据的正确访问。一个对象的方法采用synchronized进行声明,只能被一个线程访问,如果A正在执行一个同步方法syncMethodA(),线程B要执行这个对象的其他同步方法syncMethodB(),线程B将被阻塞直到线程A访问完。但如果线程B访问的是同一个类的不同对象,那么两个线程都不会被阻塞。

  4. 因为run方法的方法体不具有同步安全性,为了解决这个问题,java的多线程支持引入了同步监视器来解决这个问题,使用同步监视器的通用方法就是同步代码块。从语法的角度看,任意对象都可作为同步监视锁,从程序逻辑来看,选择竞争资源作为同步监视器。同步监视器的目的是阻止两条线程对同一个共享资源进行并发访问,因此通常推荐使用可能被并发访问的共享资源充当同步监视器

       public class A {
            //作为锁句柄的变量通常使用final修饰,如果锁句柄变量的值一旦改变
            //会导致执行同一个同步块的多个线程实际上使用不同的锁,从而导致竞态
            private final Object lock = new Object();
            public static void methodA(){
                synchronized (A.class){
                    //访问共享数据
                }
            }
        
            public  void methodB(){
                synchronized (this){
                    //访问共享数据
                }
            }
        
            public synchronized void methodC(){
                //相当于同步this
            }
        
            public  void methodD(){
                synchronized (lock){
                    //访问共享数据
                }
            }
        }
    
  5. 任何线程进入同步代码块、同步方法之前,必须先获得对同步监视器的锁定。但是程序无法显示的释放同步监视器的锁定 ,线程会在如下情况下释放对同步监视器的锁定:

    • 静态方法的默认同步锁是当前方法所在类的.class对象
    • 线程同步的关键:任意线程,进入同步监视器监视的代码之前,必须先对同步监视器加锁。
  6. 释放对同步监视器的加锁:

    • 同步代码块或同步方法执行完成
    • 在代码块遇到break、return跳出代码块
    • 执行同步代码块或同步方法时遇到未捕获的异常或Error 。
    • 调用了同步监视器的wait()方法。
  7. 线程不会释放同步监视器:

    • 程序调用Thread.sleep() Thread.yeild()
    • suspend()也不会。(尽量不使用此方法)
  8. 内部锁的使用并不会导致锁泄露,因为javac编译器在将同步块代码编译为字节码的时候,对临界区中可能抛出的而程序代码中又未捕获的异常进行了特殊处理,这使得临界区的代码即使抛出异常也不会妨碍内部锁的释放

  9. 内部锁的调度:JVM会为每个内存锁分配一个入口集(Entry Set),用于记录等待获得相应内部锁的线程。多个线程申请同一个锁的时候,只有一个申请者能够持有该锁,而其他的申请者操作会失败。这些申请失败的线程不会抛出异常,而是会被BLOCKED并存入相应锁的入口集中等待再次申请锁的机会。入口集中的线程被称为相应内部锁的等待线程。当这些线程申请的锁被其他持有线程释放的时候,该锁的入口集中的一个任意线程会被JVM唤醒,从而得到再次申请锁的机会。由于JVM对内部锁的调度仅支持非公平调度,因此唤醒的线程不一定就能成为该锁的持有者。

  10. 理论上,每个对象都可以做为锁,但一个对象做为锁时,应该被多个线程共享,这样才显得有意义,在并发环境下,一个没有共享的对象作为锁是没有意义的

    1. lock变量作为一个锁存在根本没有意义,因为它根本不是共享对象,每个线程进来都会执行Object lock=new Object();每个线程都有自己的lock,根本不存在锁竞争。
        public class ThreadTest{  
    	  public void test(){  
    	     Object lock=new Object();  
    	     synchronized (lock){  
            //do something  
    	     }  
    	  }  
    	}  
    
    
  11. 每个锁对象都有两个队列,一个是就绪队列,一个是阻塞队列,就绪队列存储了将要获得锁的线程,阻塞队列存储了被阻塞的线程,当一个被线程被唤醒(notify)后,才会进入到就绪队列,等待cpu的调度。当一开始线程a第一次执行account.add方法时,jvm会检查锁对象account的就绪队列是否已经有线程在等待,如果有则表明account的锁已经被占用了,由于是第一次运行,account的就绪队列为空,所以线程a获得了锁,执行account.add方法。如果恰好在这个时候,线程b要执行account.withdraw方法,因为线程a已经获得了锁还没有释放,所以线程b要进入account的就绪队列,等到得到锁后才可以执行。一个线程执行临界区代码过程如下:

    • 获得同步锁
    • 清空工作内存
    • 从主存拷贝变量副本到工作内存
    • 对这些变量计算
    • 将变量从工作内存写回到主存
    • 释放锁

Lock接口

  1. 显示锁是java.util.concurrent.locks.Lock接口的实例。该接口是显示锁的抽象,Lock支持更灵活的同步代码结构,Lock接口允许实现更复杂的临界区结构(控制的获取和释放不出现在同一个块结构中),Lock接口允许分离读和写操作,允许多个读线程和只有一个写线程。

    • 相比synchronizedLock接口有更好的性能(新版本jdk两者差距不大)。
    • 相比synchronized,Lock提供更多的功能。tryLcok()方法,试图获取锁,如果锁已经被其他线程获取,它将返回false并继续往下执行代码。使用synchronized时,如果线程A试图执行一个同步代码块,而线程B已在执行这个同步代码块,则线程A就会被挂起直到线程B运行完成这个同步代码块。
    • Lock接口允许分离读和写操作,允许多个读线程和只有一个写线程。
  2. ReentrantLock既支持公平锁又支持非公平锁。公平锁保障锁调度的公平往往是以增加上下文切换的代价,因此公平锁适合于锁被持有的时间相对长或者线程申请锁的平均时间相对长的情形。总得来说使用公平锁的开销比使用非公平锁的开销要大,因此ReentrantLock默认使用非公平调度策略。

  3. 锁示例

    Lock lock = new ReentrantLock();
    lock.lock();
    try{
      
    }finally{
      lock.lock();
    }
    1.finally中释放锁,目的是保证在获取到锁之后,最终能够被释放。
    2. 不要将获取锁的过程写在try块中,因为如果在获取锁时发生异常,异常抛出的同时,也会导致锁无故释放
    

synchronized与Lock对比

  1. 内部锁(synchronized)是基于代码块的锁,显示锁(Lock)是基于对象的锁。内部锁(synchronized)无法充分发挥面向对象编程的灵活性,比如内部锁的申请和释放只能在一个方法内进行,因为代码块无法跨方法,而显示锁支持在一个方法内申请锁,在另一个方法释放锁(即Lock接口允许实现更复杂的临界区结构)。
  2. 内部锁(synchronized)简单易用,且不会导致锁泄露。而显示锁(Lock)容易被错用而导致锁泄露,使用显示锁(Lock)时必须注意将锁的释放放在finally块中。
  3. 内部锁(synchronized)仅支持非公平锁,显示锁(Lock)可以支持非公平锁。
  4. 显示锁(Lock)可以对锁的相关信息进行监控,而内部锁(synchronized)不支持
  5. JDK6/7对内部锁做了一些优化,这些优化可以在特定情况下减少锁的开销。这些优化包括锁消除、锁粗化、偏向锁、适配性锁等,这些优化在JDK6/7中并没有运用到显示锁上。
  6. Lock接口允许分离读和写操作,允许多个读线程和只有一个写线程。Lock有更多的功能,比如tryLcok()方法,试图获取锁,如果锁已经被其他线程获取,它将返回false并继续往下执行代码。使用synchronized时,如果线程A试图执行一个同步代码块,而线程B已在执行这个同步代码块,则线程A就会被挂起直到线程B运行完成这个同步代码块。
    1. 针对于以下场景:先获得锁A,然后再获取锁B,当锁B获得后,释放锁A同时获取锁C,当锁C获得后再释放锁B同时获取锁D,以此类推,这种场景下,synchronized就不那么容易实现了,而LOCK却容易许多

读写锁

  1. ReentrantReadWriteLock有两个锁,一个是读操作锁,一个是写操作锁。使用读锁可以允许多个线程同时访问,但是使用写操作锁时只允许一个线程访问,线程进入写锁的前提条件:没有其他线程的读锁,没有其他线程的写锁。

  2. ReentrantLockReentrantReadWriteLock类的构造器都含有一个布尔值fair。它允许你控制这两个类的行为。默认是false,称为非公平模式,在非公平模式下,当很多线程在等待锁时,锁将选择他们中的一个来访问临界区,这个选择是没有任何约束的。如果fair值是true,则称为公平模式,在公平模式下,当很多线程在等待锁

    时,锁将选择他们中的一个来访问临界区,而且选择的是等待时间最长的。这两种模式只适用于lock()和unlock()。Lock接口的tryLock()没有将线程置于休眠,fair属性并不影响这个方法。

  3. ReentrantReadWriteLock

    • 重入方面其内部的WriteLock可以获取ReadLock,但是反过来ReadLock不能获得WriteLock
    • WriteLock可以降级为ReadLock,顺序是:先获得WriteLock再获得ReadLock,然后释放WriteLock,这时候线程将保持Readlock的持有。反过来ReadLock想要升级为WriteLock则不可能。
    • ReadLock可以被多个线程持有并且在作用时排斥任何的WriteLock,而WriteLock则是完全的互斥。这一特性最为重要,因为对于高读取频率而相对较低写入的数据结构,使用此类锁同步机制则可以提高并发量。
    • 不管是ReadLock还是WriteLock都支持Interrupt,语义与ReentrantLock一致。
    • WriteLock支持Condition并且与ReentrantLock语义一致,而ReadLock则不能使用Condition,否则抛出UnsupportedOperationException异常。

死锁

  1. 死锁:当两个或者多个线程被阻塞并且他们在等待的锁永远不会被释放时,就会发生死锁

  2. 线程A获取锁X,线程B获取锁Y,现在线程A试图获取锁Y,同时线程B也试图获取锁X,则两个线程都将被阻塞,而且他们等待的锁永远不会被释放。(两个线程都试图获取对方拥有的锁)

  3. 在程序设计时避免双方互相持有对方锁的情况。死锁与使用嵌套的synchronized还是不使用没有任何关系,只要互相等待对方释放锁就有可能出现死锁。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值