我们知道面向对象的编程思想是站在现实世界的角度去抽象和解决问题,它把数据和行为都看做是对象的一部分。当多个线程访问一个对象时如果不考虑这些线程在执行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的
Java的线程安全
我们这里讨论的线程安全,限定于多个线程之间存在共享数据访问这个前提,因为如果一段代码根本不会与其他线程共享数据,那么从线程安全的角度来看,程序是串行执行还是多线程执行对它来说是完全没有区别的。
我们可以将Java语言中各种操作共享的数据分为以下5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
不可变
在Java语言中不可变的对象一定是线程安全的,无论是对象的方法实现还是方的调用者,都不需要再采用任何的线程安全保障措施。
- 如final关键字修饰的变量,只要一个不可变对象被正确构建出来(没有发生this引用逃逸,即对象还未构造完成this引用就被发布出去了),那其外部的可见状态永远不会发生改变,永远不会看到它在多个线程之中处于不一致的状态。
- 如java.lang.String类的对象,它是一个典型的不可变对象,我们调用它substring、replace和concat这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象
- 枚举类型,以及java.lang.Number的部分子类,如Long和Double等数值包装类型,BigInteger和BigDecimal等大数据类型基本都是不可变的API;但同为Number的子类型的原子类AtomicInteger和AtomicLong则并非不可变的
这里理解一点,就是不可变的对象一定是线程安全的。
绝对线程安全
不管运行时环境如何,调用者都不需要任何额外的同步措施。java.util.Vector
是一个线程安全的容器,它的get,add,size
等方法都是被synchronized修饰的,效率很低 ,但确实是安全的。但并不意味着调用时永远不需要同步手段,如多线程中一个线程在错误的时间里对元素进行了删除,就会导致边界异常。此时删除操作需要进入同步块处理。
相对线程安全
相对的线程安全就是我们通常意义上所讲的线程安全,要保证对这个对象单独的操作是线程安全的,调用时不需要做额外的保障措施,但对一些特定的顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。 Java中大部分都属于这类,如Vector,HashTable,Collections的synchronizedCollection()方法包装的集合等。
线程兼容
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。Java API中大部分的类都是属于线程兼容的,如与前面的Vector和HashTable相对应的集合类ArrayList和HashMap等。
线程对立
线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于Java语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。
线程安全的讨论范畴
我们最终的目的就是使线程安全,其实从并发的角度来讲,按照线程安全的三种策略:
- 第一个部分,阻塞(互斥)同步,我们所讨论的锁也集中在这个部分。
- 第二个部分,非阻塞同步,这个部分也就一种通过CAS进行原子类操作,其实也就是不加锁或者代码实现一些自旋锁。
- 第三个部分,无同步方案,包括可重入代码和线程本地存储(ThreadLocal)
我们使用最多的应该就是虚拟机提供的互斥同步和锁机制,互斥同步是常见的一种并发正确性保障手段。同步是指在多线程并发访问共享数据时,保证共享数据在同一时刻只能被一个线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。互斥是因,同步是果;互斥是方法,同步是目的
Java的锁
Java提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率,我们先来看看锁的实现方式及其分类。
锁的实现方式
我们所说的锁的分类其实应该按照锁的特性和设计来划分,其实我们真正用到的锁也就那么两三种,只不过依据设计方案和性质对其进行了大量的划分。一类是原生语义上的实现
- Synchronized,它是一个:非公平,悲观,独享,互斥,可重入的重量级锁
还有一类是在JUC包下,是API层面上的实现
- ReentrantLock,它是一个:默认非公平但可实现公平的,悲观,独享,互斥,可重入的重量级锁。
- ReentrantReadWriteLocK,它是一个,默认非公平但可实现公平的,悲观,写独享/读共享,读写,可重入的重量级锁
那么我们来详细了解下这几种实现方式。
Synchronized
synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。
- 如果Java程序中的synchronized明确指定了对象参数,那就是这个对象的reference;
- 如果synchronized修饰的是实例方法,去取对应的对象实例
- 如果synchronized修饰的是类方法,去取对应的Class对象来作为锁对象
那么Synchronized实现的锁有什么优缺点呢?
Synchronized优点
在虚拟机规范对monitorenter和monitorexit的行为描述中,有两点是需要特别注意的。
- 首先,synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。 可重⼊锁概念是:⾃⼰可以再次获取⾃⼰的内部锁。⽐如⼀个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重⼊的话,就会造成死锁。同⼀个线程每次获取锁,锁的计数器都⾃增1,所以要等到锁的计数器下降为0时才能释放锁
- 其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入
也就是Synchronized能保证同步块内的内容在多线程下准确执行
Synchronized缺点与优化
其缺点也比较明显,Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。所以synchronized是Java语言中一个重量级的操作,优化方式就是在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态之中,也就是在直接用重量级锁之前,先让轻量级锁自旋等待下。
ReentrantLock
除了synchronized之外,我们还可以使用java.util.concurrent(下文称JUC)包中的重入锁ReentrantLock
来实现同步,ReentrantLock与synchronized很相似,他们都具备一样的线程重入特性,只是代码写法上有点区别,
- 一个表现为API层面的互斥锁(lock和unlock方法配合try/finally语句块来完成)
- 一个表现为原生语法层面的互斥锁。
相比synchronized,ReentrantLock增加了一些高级功能,主要有以下3项:定时锁等候/等待可中断、可实现公平锁,以及锁可以绑定多个条件
定时锁等候/等待可中断
ReentrantLock获取锁定有四种方式:
- lock(), 如果获取了锁立即返回,如果别的线程持有锁,当前线程则一直处于休眠状态,直到获取锁
- tryLock(), 如果获取了锁立即返回true,如果别的线程正持有锁,立即返回false
- tryLock(long timeout,TimeUnit unit), 如果获取了锁定立即返回true,如果别的线程正持有锁,会等待参数给定的时间,在等待的过程中,如果获取了锁定,就返回true,如果等待超时,返回false;定时锁等候
- lockInterruptibly():如果获取了锁定立即返回,如果没有获取锁定,当前线程处于休眠状态,直到获取锁定,或者当前线程被别的线程中断,中断锁等候
可中断特性对处理执行时间非常长的同步块很有帮助,举例说明,线程A和B都要获取对象O的锁定,假设A获取了对象O锁,B将等待A释放对O的锁定
- 如果使用 synchronized ,如果A不释放,B将一直等下去,不能被中断
- 如果 使用ReentrantLock,如果A不释放,可以使B在等待了足够长的时间以后,中断等待,而干别的事情
所以这个特性还是很重要的。
可实现公平锁
公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized
中的锁是非公平的,ReentrantLock
默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁
锁绑定多个条件
锁绑定多个条件是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait
和notify
或notifyAll
方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock
则无须这样做,只需要多次调用newCondition
方法即可。
Synchronized和ReentrantLock区别
Synchronized和ReentrantLock有什么区别和联系呢?,可以总结为以下几点:
- 两者默认都是非公平,悲观,独享,互斥,可重入锁
- synchronized 依赖于 JVM ⽽ ReentrantLock 依赖于 API,不但可以通过一些监控工具监控synchronized的锁定,而且在代码执行时出现异常,JVM会自动释放锁定,但是使用Lock则不行,lock是通过代码实现的,要保证锁定一定会被释放,就必须将unLock放到finally{}中
- ReentrantLock 拥有Synchronized相同的并发性和内存语义,此外还多了 可实现选择性通知(锁可以绑定多个条件),定时锁等候/等待可中断,可实现公平锁
需要注意,随着Synchronized的优化,性能已不能作为二者比较的标准。
锁的分类
了解了具体的锁的实现之后我们再来看看从功能的角度出发,锁是如何进行分类的
乐观锁和悲观锁
乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度,对于同一个数据的并发操作,
- 悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。
- 乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。
悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的
乐观锁的实现方式
乐观锁的实现方式主要有两种,一种是CAS(Compare and Swap,比较并交换)机制,一种是版本号机制。
- CAS机制,CAS操作包括了三个操作数,分别是需要读取的内存位置(V)、进行比较的预期值(A)和拟写入的新值(B),操作逻辑是,如果内存位置V的值等于预期值A,则将该位置更新为新值B,否则不进行操作。另外,许多CAS操作都是自旋的,意思就是,如果操作不成功,就会一直重试,直到操作成功为止。
- 版本号机制,版本号机制的基本思路,是在数据中增加一个version字段用来表示该数据的版本号,每当数据被修改版本号就会加1。当某个线程查询数据的时候,会将该数据的版本号一起读取出来,之后在该线程需要更新该数据的时候,就将之前读取的版本号与当前版本号进行比较,如果一致,则执行操作,如果不一致,则放弃操作。
CAS指令需要有3个操作数,分别是内存位置(在Java中可以简单理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作。