【Java并发编程 六】Java线程安全与同步方案(上)

简介: 【Java并发编程 六】Java线程安全与同步方案(上)

我们知道面向对象的编程思想是站在现实世界的角度去抽象和解决问题,它把数据和行为都看做是对象的一部分。当多个线程访问一个对象时如果不考虑这些线程在执行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全

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中,锁对象的waitnotifynotifyAll方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而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的旧值,上述的处理过程是一个原子操作

相关文章
|
3月前
|
安全 Java 程序员
深入理解Java内存模型与并发编程####
本文旨在探讨Java内存模型(JMM)的复杂性及其对并发编程的影响,不同于传统的摘要形式,本文将以一个实际案例为引子,逐步揭示JMM的核心概念,包括原子性、可见性、有序性,以及这些特性在多线程环境下的具体表现。通过对比分析不同并发工具类的应用,如synchronized、volatile关键字、Lock接口及其实现等,本文将展示如何在实践中有效利用JMM来设计高效且安全的并发程序。最后,还将简要介绍Java 8及更高版本中引入的新特性,如StampedLock,以及它们如何进一步优化多线程编程模型。 ####
61 0
|
1月前
|
Linux
Linux编程: 在业务线程中注册和处理Linux信号
本文详细介绍了如何在Linux中通过在业务线程中注册和处理信号。我们讨论了信号的基本概念,并通过完整的代码示例展示了在业务线程中注册和处理信号的方法。通过正确地使用信号处理机制,可以提高程序的健壮性和响应能力。希望本文能帮助您更好地理解和应用Linux信号处理,提高开发效率和代码质量。
51 17
|
1月前
|
Linux
Linux编程: 在业务线程中注册和处理Linux信号
通过本文,您可以了解如何在业务线程中注册和处理Linux信号。正确处理信号可以提高程序的健壮性和稳定性。希望这些内容能帮助您更好地理解和应用Linux信号处理机制。
61 26
|
3月前
|
安全 Java 编译器
深入理解Java中synchronized三种使用方式:助您写出线程安全的代码
`synchronized` 是 Java 中的关键字,用于实现线程同步,确保多个线程互斥访问共享资源。它通过内置的监视器锁机制,防止多个线程同时执行被 `synchronized` 修饰的方法或代码块。`synchronized` 可以修饰非静态方法、静态方法和代码块,分别锁定实例对象、类对象或指定的对象。其底层原理基于 JVM 的指令和对象的监视器,JDK 1.6 后引入了偏向锁、轻量级锁等优化措施,提高了性能。
103 3
|
3月前
|
存储 安全 Java
Java多线程编程秘籍:各种方案一网打尽,不要错过!
Java 中实现多线程的方式主要有四种:继承 Thread 类、实现 Runnable 接口、实现 Callable 接口和使用线程池。每种方式各有优缺点,适用于不同的场景。继承 Thread 类最简单,实现 Runnable 接口更灵活,Callable 接口支持返回结果,线程池则便于管理和复用线程。实际应用中可根据需求选择合适的方式。此外,还介绍了多线程相关的常见面试问题及答案,涵盖线程概念、线程安全、线程池等知识点。
298 2
|
3月前
|
缓存 Java 调度
多线程编程核心:上下文切换深度解析
在现代计算机系统中,多线程编程已成为提高程序性能和响应速度的关键技术。然而,多线程编程中一个不可避免的概念就是上下文切换(Context Switching)。本文将深入探讨上下文切换的概念、原因、影响以及优化策略,帮助你在工作和学习中深入理解这一技术干货。
75 10
|
3月前
|
安全 算法 Java
Java多线程编程中的陷阱与最佳实践####
本文探讨了Java多线程编程中常见的陷阱,并介绍了如何通过最佳实践来避免这些问题。我们将从基础概念入手,逐步深入到具体的代码示例,帮助开发者更好地理解和应用多线程技术。无论是初学者还是有经验的开发者,都能从中获得有价值的见解和建议。 ####
|
3月前
|
Java 调度
Java中的多线程编程与并发控制
本文深入探讨了Java编程语言中多线程编程的基础知识和并发控制机制。文章首先介绍了多线程的基本概念,包括线程的定义、生命周期以及在Java中创建和管理线程的方法。接着,详细讲解了Java提供的同步机制,如synchronized关键字、wait()和notify()方法等,以及如何通过这些机制实现线程间的协调与通信。最后,本文还讨论了一些常见的并发问题,例如死锁、竞态条件等,并提供了相应的解决策略。
81 3
|
3月前
|
算法 调度 开发者
多线程编程核心:上下文切换深度解析
在多线程编程中,上下文切换是一个至关重要的概念,它直接影响到程序的性能和响应速度。本文将深入探讨上下文切换的含义、原因、影响以及如何优化,帮助你在工作和学习中更好地理解和应用多线程技术。
68 4
|
3月前
|
安全 Java API
【JavaEE】多线程编程引入——认识Thread类
Thread类,Thread中的run方法,在编程中怎么调度多线程

热门文章

最新文章