多线程编程(二)

在Java多线程中,可以使用synchronized关键字来实现线程之间同步互斥,在JDK1.5以后,Java类库中新增了Lock接口用来实现类似的锁功能。下面会逐一介绍关于Java类库中所提供的锁功能。

锁可以理解为对共享数据进行保护的许可证,对于同一把锁保护的共享数据而言,任何线程对这些共享数据的访问都需要先持有该锁。一把锁只能同时被一个线程持有,当以一个该锁的持有线程对共享数据访问结束之后必须释放该锁,以便让其他线程持有。锁的持有线程在锁的获得和锁的释放之间的这段时间所执行的代码被称为临界区。

锁能够保护共享数据以实现线程安全,锁的主要作用有保障原子性、保障可见性和保障有序性。由于锁具有互斥性,因此当线程执行临界区中的代码时,其他线程无法做到干扰,临界区中的代码也就具有了不可分割的原子特性。

锁具有排他性,即一个锁一次只能被一个线程持有,这种锁又被称之为排他锁或互斥锁。当然,新版本的JDK中为了性能优化还推出了另一种锁——读写锁,读写锁是作为了排它锁的一种改进而存在的。

按照Java虚拟机对锁的实现方式划分,Java平台中的锁包括内部锁(主要是通过synchronized实现)和显式锁(主要是通过Lock接口及其实现类实现),下文将逐一介绍。

公平锁和非公平锁:

锁Lock分为”公平锁”和”非公平锁”,公平锁表示线程获取锁的顺序是按照线程加锁的顺序来分配的,即先来先得的FIFO先进先出顺序。非公平锁就是一种获取锁的抢占机制,是随机获得锁的,和公平锁不一样的就是先来的不一定先得到锁,这个方式可能造成某些线程一直拿不到锁,结果也就是不公平的了。

内部锁属于非公平锁,而显式锁不仅支持公平锁而且支持非公平锁。

内部锁——众所周知的synchronized

Java平台中的任何一个对象都有唯一一个与之关联的锁,这种锁被称之为监视器(或者叫内部锁)。内部锁是一种排它锁,它能保证原子性、可见性和有序性。内部锁就由synchronized关键字实现。

synchronized可以修饰方法或者代码块。当synchronized修饰方法的时候,该方法内部的代码就属于一个临界区,该方法就属于一个同步方法。此时一个线程对该方法内部的变量的更新就保证了原子性和可见性,从而实现了线程安全。当synchronized修饰代码块的时候,需要一个锁句柄(一个对象的引用或者是一个可以返回对象的表达式),此时synchronized关键字引导的代码块就是临界区;同步块的锁句柄可以写为this关键字,此时表示为当前对象,锁句柄对应的监视器就被称之为相应同步块的引导锁。

作为锁句柄的变量通常以private final修饰,防止锁句柄变量的值改变之后,导致执行同一个同步块的多个线程使用不同的锁,从而避免了竞态。

同步实例方法相当于以”this”为引导锁的同步块;同步静态方法相当于以当前类对象为引导锁的同步块。

线程读内部锁的申请和释放均由Java虚拟机负责代为实施,内部锁的使用不会导致锁泄漏,这是因为Java编译器在将同步块代码编译成字节码的时候,对临界区中可能抛出的而程序代码中又未捕获的异常进行了特殊的处理,这使得临界区的代码即使抛出异常也不会妨碍内部锁的释放。

注意Java虚拟机会为每一个内部锁分配一个入口集用于存放等待获得相应内部锁的线程,当内部锁的持有线程释放当前锁的时候,可能是入口集中处于BLOCKED状态的线程获得当前锁也可能是处于RUNNABLE状态的其他线程。内部锁的竞争是激烈的,也是不公平的,可能等待了长时间的线程没有获得锁,也可能是没有经过等待的线程直接就获得了锁。

显式的加锁和解锁——Lock接口

在Java5.0之前,在协调对共享对象的访问时可以使用的机制只有synchronized和volatile。在Java 5.0中增加了一种新的机制:Lock接口(以及其实现类如ReentrantLock等),Lock接口中定义了一组抽象的加锁操作。与synchronized不同的是,synchronized可以方便的隐式的获取锁,而Lock接口则提供了一种显式获取锁的特性。

显式锁是自从JDK1.5之后开始引入的排它锁。显式锁是Lock接口的实例,Lock接口的默认实现类是ReentrantLock。

重入锁——ReentrantLock类

在详细介绍关于ReentrantLock类的详细信息之前,先介绍一下锁的可重入性的概念。

    如果一个线程持有一个锁的时候还能继续成功的申请该锁,那么我们就称该锁是可重入的,否则我们就称该锁是非可重入的。

ReentrantLock是一个可重入锁,ReentrantLock类与synchronized类似,都可以实现线程之间的同步互斥。但ReentrantLock类此外还扩展了更多的功能,如嗅探锁定、多路分支通知等,在使用上也比synrhronized更加的灵活。

上面已经提到ReentrantLock是一个既支持公平支持非公平的显示锁,所以在实例化ReentrantLock类的时候我们可以明确的看到ReentrantLock的一个构造签名为ReentrantLock(boolean fair),当我们传入true的时候得到的锁是一个公平锁。公平锁的开销较非公平锁的开销大,因此显式锁默认使用的是非公平的调度策略。由于ReentrantLock可以具有公平性,因此:

默认情况下使用内部锁,而当多数线程持有一个锁的时间相对较长或者线程申请锁的平均时间间隔相对长的情况下我们可以考虑使用显式锁。

读写锁——(Read/Write Lock)

读写锁是一种改进型的排它锁。读写锁允许多个线程可以同时读取(只读)共享变量。读写锁是分为读锁和写锁两种角色的,读线程在访问共享变量的时候必须持有相应读写锁的读锁,而且读锁是共享的、多个线程可以共同持有的;写锁是排他的,以一个线程在持有写锁的时候,其他线程无法获得相应锁的写锁或读锁。总之,读写锁通过读写锁的分离从而提高了并发性。

ReadWriteLock接口是对读写锁的抽象,其默认的实现类是ReentrantReadWriteLock。ReadWriteLock定义了两个方法readLock()和writeLock(),分别用于返回相应读写锁实例的读锁和写锁。这两个方法的返回值类型都是Lock。

读写锁主要用于读线程持有锁的时间比较长的情景下。

锁的替代

多个线程共享同一个非线程安全对象时,我们往往采用锁来保证线程安全性。但是,锁也有其弊端,比如锁的开销和在使用锁的时候容易发生死锁等。所以在Java中也提供了一些对于某些情况下替代锁的同步机制解决方案,如volatile关键字、final关键字、static关键字、原子变量以及各种并发容器和框架,这些大多数内容我将以后介绍;此外我们还可以采用一定的多线程设计模式来完成多线程的同步。

首先介绍在并发程序设计中,我们使用和共享对象可以采用的一些策略。上面所提到的Java内置的一些工具类和关键字以及我们所采用的设计模式大多都基于这些策略的思想。

  1. 采用线程特有对象: 各个不同的线程创建各自的实例,一个实例只能被一个线程访问的对象就被称之为线程的特有对象。采用线程特有对象,保障了对非线程安全对象的访问的线程安全。
  2. 只读共享:在没有额外同步的情况下,共享的只读对象可以有可以由多个线程并发访问,但是任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
  3. 线程安全共享:线程安全的对象在其内部实现同步,多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。
  4. 保护对象:被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。

这里我首先介绍其中的volatile关键字、ThreadLocal二者在锁的某些功能上的替代作用。

volatile关键字

通过volatile关键字的使用,我们可以保证共享变量的可见性和有序性。不同于常见的锁,在原子性方面,volatile仅能保障写volatile变量操作的原子性,没有锁的排他性;此外,volatile关键字的使用不会引起上下文的切换,因此volatile常被称为轻量级锁。

多线程编程基础一文中,我已经初步介绍了Java的内存模型。volatile最主要的就是实现了共享变量的内存可见性,其实现的原理是:volatile变量的值每次都会从高速缓存或者主内存中读取,对于volatile变量,每一个线程不再会有一个副本变量,所有线程对volatile变量的操作都是对同一个变量的操作。

volatile变量的开销包括读变量和写变量两个方面。volatile变量的读、写操作都不会导致上下文的切换,因此volatile的开销比锁小。但是volatile变量的值不会暂存在寄存器中,因此读取volatile变量的成本要比读取普通变量的成本更高。

ThreadLocal

ThreadLocal,即线程变量,是一个以ThreadLocal对象为键、任意对象为值的存储结构。这个结构被附带在线程上,也就是说一个线程可以根据一个ThreadLocal对象查询到绑定在这个线程上的一个值。

ThreadLocal采用的是上述策略中的第一种设计思想——采用线程的特有对象.采用线程的特有对象,我们可以保障每一个线程都具有各自的实例,同一个对象不会被多个线程共享,ThreadLocal是维护线程封闭性的一种更加规范的方法,这个类能使线程中的某个值与保存值的对象关联起来,从而保证了线程特有对象的固有线程安全性。

ThreadLocal类相当于线程访问其线程特有对象的代理,即各个线程通过这个对象可以创建并访问各自的线程特有对象,泛型T指定了相应线程持有对象的类型。一个线程可以使用不同的ThreadLocal实例来创建并访问其不同的线程持有对象。多个线程使用同一个ThreadLocal实例所访问到的对象时类型T的不同实例。代理的关系图如下:

Aaron Swartz

ThreadLocal提供了get和set等访问接口或方法,这些方法为每一个使用该变量的线程都存有一份独立的副本,因此get总是能返回由当前执行线程在调用set时设置的最新值。其主要使用的方法如下:

    public T get(): 获取与当前线程中ThreadLocal实例关联的线程特有对象。
    public void set(T value):重新关联当前线程中ThreadLocal实例所对应的线程特有对象。
    protected T initValue():如果没有调用set(),在初始化threadlocal对象的时候,该方法的返回值就是当前线程中与ThreadLocal实例关联的线程特有对象。
    public void remove():删除当前线程中ThreadLocal和线程特有对象的关系。

那么ThreadLocal底层是如何实现Thread持有自己的线程特有对象的?查看set()方法的源代码:

Aaron Swartz

Aaron Swartz
可以看到,当我们调用threadlocal的set方法来保存当前线程的特有对象时,threadlocal会取出当前线程关联的threadlocalmap对象,然后调用ThreadLocalMap对象的set方法来进行当前给定值的保存。

Aaron Swartz

每一个Thread都会维护一个ThreadLocalMap对象,ThreadLocalMap是一个类似Map的数据结构,但是它没有实现任何Map的相关接口。ThreadLocalMap是一个Entry数组,每一个Entry对象都是一个”key-value”结构,而且Entry对象的key永远都是ThreadLocal对象。当我们调用ThreadLocal的set方法时,实际上就是以当前ThreadLocal对象本身作为key,放入到了ThreadLocalMap中。

可能发生内存泄漏:

通过查看Entry结构可知,Entry属于WeakReference类型,因此Entry不会阻止被引用的ThreadLocal实例被垃圾回收。当一个ThreadLocal实例没有对其可达的强引用时,这个实例就可以被垃圾回收,即其所在的Entry的key会被置为null,但是如果创建ThreadLocal的线程一直持续运行,那么这个Entry对象中的value就有可能一直得不到回收,从而发生内存泄露。

解决内存泄漏的最有效方法就是,在使用完ThreadLocal之后,要注意调用threadlocal的remove()方法释放内存。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值