线程安全
锁机制的本质
在多个线程访问共同的资源时,在某⼀个线程对资源进行写操作的中途(写入已经开始,但还没结束),其他线程对这个写了一半的资源进行行了读操作,或者基于这个写了一半的资源进行了写操作,导致出现数据错误。
通过对共享资源进行访问限制,让同一时间只有一个线程可以访问资源,保证了数据的准确性。
不论是线程安全问题,还是针对线程安全问题所衍生出的锁机制,它们的核心都在于共享的资源,而不是某个方法或者某几行代码。
成员变量(静态的也一样)共享引起的安全问题,局部变量的话就不会
Synchronized
解决思路
就是将多条操作共享数据的线程代码封装起来,当有线程在执行这些代码的时候,其他线程时不可以参与运算。必须要当前线程把这些代码都执行完毕后,其他线程才可以参与运算。
使用锁机制:synchronized 或 lock 对象
同步的好处:解决了线程的安全问题。
同步的弊端:相对降低了效率,因为同步外的线程的都会判断同步锁。
同步的前提:同步中必须有多个线程并使用同一个锁。
当一个线程进入一个对象的一个 synchronized 方法后,其它线程是否可进入此对象的其它方法?
不能,一个对象的一个 synchronized 方法只能由一个线程访问。
同步函数和同步代码块
同步函数的锁是固定的 this。同步代码块的锁是任意的对象。建议使用同步代码块。
静态方法的同步函数的锁是 class 类,不是 this 对象,会被叫作 类锁(class对象锁),静态方法不属于某个对象,多个类是共享的
和 Lock 的异同
- jdk1.5以后将同步和锁封装成了对象。并将操作锁的隐式方式定义到了该对象中,将隐式动作变成了显示动作。
- synchronized 会自动释放锁,而 Lock 一定要求程序员手工释放,并且最好在 finally 块中释放(这是释放外部资源的最好的地方),非公平锁
- Lock 接口: 出现替代了同步代码块或者同步函数。将同步的隐式锁操作变成现实锁操作。同时更为灵活。可以一个锁上加上多组监视器。
- lock (): 获取锁。unlock (): 释放锁,通常需要定义 finally 代码块中。
原理
- Synchronized 是 Java 中用于实现多线程同步的关键字,通过它可以实现线程的互斥访问和共享资源的同步访问。其底层原理是通过 Java 虚拟机中的对象监视器来实现的。每个 Java 对象都有一个对象监视器,用于控制对该对象的访问
- 当一个线程想要访问一个被 synchronized 关键字所保护的代码块时,它必须先获得该对象的对象监视器锁,其他线程将会被阻塞,直到该线程释放该锁。
- 在 Java 虚拟机中,对象的监视器锁是由对象头中的标志位来表示的。当一个线程获得了对象监视器锁时,该标志位被设置为 1,其他线程将无法获得该锁。
- 当该线程释放锁时,标志位被设置为 0,其他线程才能获得该锁。这种机制可以保证同一时刻只有一个线程能够访问被 synchronized 所保护的代码块,从而实现线程的同步和互斥。
常见术语
悲观锁和乐观锁
- 乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测
- 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。
- 传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java 中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。
- 乐观锁,总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁。更多的时候用在数据库里 (经常改变,先判断,如果需要的话再锁,悲观锁是直接就锁))。
- 然而在真实环境中,大部分时候都不会产生冲突。而乐观锁不一样,它假设不会产生冲突,先去尝试执行某项操作,失败了再进行其他处理(一般都是不断循环重试),这种锁不会阻塞其他线程,也不涉及上下文切换,性能开销小,代表实现是 CAS。
公平锁和非公平锁
公平锁是指各个线程在获取锁前先检查有无排队的线程,按排队顺序去获取锁。非公平锁是指线程获取锁前不考虑排队问题,直接尝试获取锁。值得注意是,在 AQS 的实现中,一旦线程进入排队队列,即使是非公平锁,线程也得乖乖排队。
可重入锁和不可重入锁
- 如果一个线程已经获取到了锁,那么它可以访问被这个锁锁住的所有代码块,不可重入锁与之相反。可重入锁包括 synchronized 和 ReentrantLock。
- 当一个线程获取了锁时,其他线程将被阻塞,直到该线程释放了锁。在某些情况下,同一个线程可能需要多次获取同一个锁,这就是可重入性的概念。
- 可重入锁允许同一个线程在持有锁的情况下再次获取该锁,而不会产生死锁或其他问题。也就是说,当一个线程已经获得了锁之后,它可以继续多次获得同一个锁,而不会被自己所持有的锁所阻塞。
读写锁
ReadWriteLock意义:读读不互斥、读写互斥、写写互斥共享
1 |
|
可重入锁
finally 的作用:保证在方法提前结束或出现 Exception 的时候,依然能正常释放锁。
ReentrantLock 是 Java 中的一个可重入锁,它可以用来实现线程之间的同步。与 synchronized 关键字相比,ReentrantLock 提供了更多的灵活性和功能,例如可中断锁、公平锁、多条件变量等。
ReentrantLock 的主要特点如下:
- 可重入性:ReentrantLock 是可重入锁,即同一个线程可以多次获得同一个锁,而不会出现死锁等问题。
- 可中断性:ReentrantLock 支持可中断锁,即当一个线程等待锁的时候,可以通过调用 lockInterruptibly ()方法来中断等待,从而避免死锁等问题。
- 公平性:ReentrantLock 支持公平锁和非公平锁。公平锁会按照线程的请求顺序来获取锁,而非公平锁则不保证获取锁的顺序。
条件变量:ReentrantLock 支持多个条件变量,可以通过 newCondition ()方法来创建条件变量,从而实现更加灵活的线程同步。
1 |
|
在这个示例代码中,我们创建了两个线程 thread1和 thread2,它们都会等待条件变量 mCondition。在 main ()方法中,我们等待1秒钟后唤醒了一个线程。当线程被唤醒后,它会执行任务并释放锁。这样,我们就可以使用 ReentrantLock 和条件变量来实现线程之间的同步
Semaphore 信号量
Semaphore(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源,在操作系统中是一个非常重要的问题,可以用来解决哲学家就餐问题。
Semaphore 和 ReentrantLock 类似,获取许可有公平策略和非公平许可策略,默认情况下使用非公平策略。
Semaphore 可以用来做流量分流,特别是对公共资源有限的场景,比如数据库连接。假设有这个的需求,读取几万个文件的数据到数据库中,由于文件读取是 IO 密集型任务,可以启动几十个线程并发读取,但是数据库连接数只有10个,这时就必须控制最多只有10个线程能够拿到数据库连接进行操作。这个时候,就可以使用 Semaphore 做流量控制。
1 |
|
Volatile
Java 内存模型
Java 内存模型定义了一种多线程访问 Java 内存的规范。
Java 内存模型将内存分为了主内存和工作内存。类之间共享的变量,是存储在主内存中的,每次 Java 线程用到这些主内存中的变量的时候,会读一次主内存中的变量,并在自己的工作内存中拷贝一份,运行自己线程代码的时候,用到这些变量,操作的都是自己工作内存中的那一份。在线程代码执行完毕之后,会将最新的值更新到主内存中去
原子性:即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
有序性:即程序执行的顺序按照代码的先后顺序执行。
可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
Java重排序是指Java虚拟机(JVM)在执行Java字节码时,可能会改变指令的执行顺序。这主要有以下几个原因:
- 提高性能:现代处理器都采用了流水线技术,可以并行执行多个指令。为了充分利用处理器的性能,JVM 可能会对指令进行重排序,以便让更多的指令可以并行执行。编译成目标平台对应的代码
- 降低功耗:指令重排序可以减少指令的执行时间,从而降低功耗。
- 简化代码:编译器可以利用指令重排序来简化代码,从而提高代码的执行效率。
作用
volatile关键字在Android中到底有什么用?_android volatile关键字的作用-CSDN博客
volatile 关键字的作用主要有两个:
可见性
使用 volatile 关键字修饰的变量,保证了其在多线程之间的可见性
当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。保证了每次读取到 volatile 变量,一定是最新的数据。
volatile 关键字可以保证多线程之间的可见性,即当一个线程修改了 mAtomicInteger 的值时,其他线程可以立即看到这个修改。而 AtomicInteger 是一个原子类,可以保证对它的操作是原子性的,即多个线程同时对它进行操作时,不会出现数据冲突和安全问题。
因此,将 mAtomicInteger 声明为 volatile AtomicInteger 可以保证多线程之间对它的操作是线程安全的。同时,使用 AtomicInteger 可以避免使用 synchronized 关键字来进行同步,从而提高程序的性能。
有序性
代码底层执行不像我们看到的高级语言,Java 程序这么简单,它的执行是 Java 代码–>字节码–>根据字节码执行对应的 C/C++代码–>C/C++代码被编译成汇编语言–>和硬件电路交互,现实中,为了获取更好的性能 JVM 可能会对指令进行重排序,多线程下可能会出现一些安全的问题。使用 volatile 则会对禁止语义重排序,当然这也一定程度上降低了代码执行效率
1 |
|
效率
volatile 变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。不过即便如此,大多数场景下 volatile 的总开销仍然要比锁低。
volatile 的标志位
volatile 关键字在 Java 字节码中会标记为 ACC_VOLATILE
标志位。JVM 会根据这个标志位来实现 volatile 的可见性。
为什么不能保证原子性
- 一个变量 i 被 volatile 修饰,两个线程想对这个变量修改,都对其进行自增操作也就是 i++,i++的过程可以分为三步,首先获取 i 的值,其次对 i 的值进行加1,最后将得到的新值写会到工作内存中。
- 线程 A 首先得到了 i 的初始值100,但是还没来得及修改,就阻塞了,这时线程 B 开始了,它也得到了 i 的值,由于 i 的值未被修改,即使是被 volatile 修饰,主存的变量还没变化,那么线程 B 得到的值也是100,之后对其进行加1操作,得到101后,将新值写入到工作内存中,再刷入主存中。根据可见性的原则,这个主存的值可以被其他线程可见。
- 问题来了,线程 A 已经读取到了 i 的值为100,也就是说读取的这个原子操作已经结束了,所以这个可见性来的有点晚,线程 A 阻塞结束后,继续将100这个值加1,得到101,再将值写到缓存,最后刷入主存,所以即便是 volatile 具有可见性,也不能保证对它修饰的变量具有原子性。
Synchronized 和 Volatile
- Synchronized 保证内存可见性和操作的原子性 ,保证不了有序性
- Volatile 只能保证内存可见性,有序性,保证不了原子性
- Volatile 不需要加锁,比 Synchronized 更轻量级,并不会阻塞线程(volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。)
- volatile 标记的变量不会被编译器优化, 而 synchronized 标记的变量可以被编译器优化(如编译器重排序的优化).
- volatile 是变量修饰符,仅能用于变量,而 synchronized 是一个方法或块的修饰符。
- volatile 本质是在告诉 JVM 当前变量在寄存器中的值是不确定的,使用前,需要先从主存中读取,因此可以实现可见性。而对 n=n+1, n++等操作时,volatile 关键字将失效,不能起到像 synchronized 一样的线程同步(原子性)的效果。
使用场景
下面的这类场景就很适合使用 volatile 变量来控制并发,当 shutdown()方法被调用时,能保证所有线程中执行的 doWork()方法都立即停下来
1 |
|
其他
- 我们在编写代码时一般不需要把用到的 long 和 double 变量专门声明为 volatile。
- 除了 volatile 之外,Java 还有两个关键字能实现可见性,即 synchronized 和 final
先行发生原则
判断是否安全
1 |
|
假设存在线程 A 和 B,线程 A 先(时间上的先后)调了“setValue(1)”,然后线程 B 调用了同一个对的“getValue()”,那么线程 B 收到的返回值是什么?
这里面的操作不是线程安全的。
那怎么修复这个问题呢?我们至少有两种比较简单的方案可以选择:要么把 getter/setter 方法都定义为 synchronized 方法,这样就可以套用管程锁定规则;
要么把 value 定义为 volatile 变量,由于 setter 方法对 value 的修改不依赖 value 的原值,满足 volatile 关键字使用场景,这样就可以套用 volatile 变量规则来实现先行发生关系。
线程安全的实现方法
互斥同步
synchronized 互斥锁
互斥同步(Mutual Exclusion&Synchronization)是常见的一种并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。因此,在这4个字里面,互斥是因,同步是果;互斥是方法,同步是目的。
在 Java 中,最基本的互斥同步手段就是 synchronized 关键字,synchronized 关键字经过编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令,这两个字节码都需要一个 reference 类型的参数来指明要锁定和解锁的对象。如果 Java 程序中的 synchronized 明确指定了对象参数,那就是这个对象的 reference;如果没有明确指定,那就根据 synchronized 修饰的是实例方法还是类方法,去取对应的对象实例或 Class 对象来作为锁对象。
根据虚拟机规范的要求,在执行 monitorenter 指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行 monitorexit 指令时会将锁计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。
除了 synchronized 之外,我们还可以使用 java. util. concurrent(下文称J.U.C)包中的重入锁(ReentrantLock)来实现同步,相比 synchronized, ReentrantLock 增加了一些高级功能,主要有以下3项:等待可中断、可实现公平锁,以及锁可以绑定多个条件。
公平锁
指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但可以通过带布尔值的构造数要求使用公平锁。
如果读者的程序是使用 JDK 1.6或以上部署的话,性能因素就不再是选择
ReentrantLock 的理由了,虚拟机在未来的性能改进中肯定也会更加偏向于原生的 synchronized,所以还是提倡在 synchronized 能实现需求的情况下,优先考虑使用 synchronized 来进行同步。
非阻塞同步(CAS)
互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。
乐观:基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。
使用 AtomicInteger 代替 int 后,程序输出了正确的结果,一切都要归功于 incrementAndGet()方法的原子性。它的实现其实非常简单。
1 |
|
CAS 操作
线程在读取数据时不进行加锁,在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。
CAS指令需要有3个操作数,分别是内存位置(在 Java 中可以简单理解为变量的内存地址,用 V 表示)、旧的预期值(用 A 表示)和新值(用 B 表示)。CAS 指令执行时,当且仅当 V 符合旧预期值 A 时,处理器用新值 B 更新 V 的值,否则它就不执行更新,但是无论是否更新了 V 的值,都会返回 V 的旧值,上述的处理过程是一个原子操作。
incrementAndGet()方法在一个无限循环中,不断尝试将一个比当前值大1的新值赋给自己。如果失败了,那说明在执行“获取-设置”操作的时候值已经有了修改,于是再次循环进行下一次操作,直到设置成功为止。
尽管 CAS 看起来很美,但显然这种操作无法涵盖互斥同步的所有使用场景,并且 CAS 从语义上来说并不是完美的,存在这样的一个逻辑漏洞:如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然为 A 值,那我们就能说它的值没有被其他线程改变过了吗?如果在这段期间它的值曾经被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。这个漏洞称为 CAS 操作的“ABA”问题。J.U.C 包为了解决这个问题,提供了一个带有标记的原子引用
类“AtomicStampedReference”,它可以通过控制变量值的版本来保证 CAS 的正确性。不过目前来说这个类比较“鸡肋”,大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。
无同步方案 (ThreadLocal)
要保证线程安全,并不是一定就要进行同步,两者没有因果关系。同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的,笔者简单地介绍其中的两类。
我们可以通过一个简单的原则来判断代码是否具备可重入性:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。
可以通过 java. lang. ThreadLocal 类来实现线程本地存储的功能。每一个线程的 Thread 对象中都有一个 ThreadLocalMap 对象,这个对象存储了一组以 ThreadLocal. threadLocalHashCode 为键,以本地线程变量为值的 K-V 值对,ThreadLocal 对象就是当前线程的 ThreadLocalMap 的访问入口,每一个 ThreadLocal 对象都包含了一个独一无二的threadLocal HashCode值,使用这个值就可以在线程 K-V 值对中找回对应的本地线程变量。
[[ThreadLocal]]
锁优化
能省锁就省锁,锁的粒度粗话,不要的地方不锁,append()操作之前直至最后一个 append()操作之后,这样只需要加锁一次就可以了。还有 cas
高效并发是从 JDK 1.5到 JDK 1.6的一个重要改进,HotSpot 虚拟机开发团队在这个版本上花费了大量的精力去实现各种锁优化技术,如适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁粗化(Lock Coarsening)、轻量级锁(Lightweight Locking)和偏向锁(Biased Locking)等,这些技术都是为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。
自旋锁与自适应自旋
自旋锁是一种互斥锁的实现方式而已,相比一般的互斥锁会在等待期间放弃 cpu,自旋锁(spinlock) 则是不断循环并测试锁的状态,这样就一直占着 cpu。
互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快锁。
自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次,用户可以使用参数-XX:PreBlockSpin 来更改。
在 JDK 1.6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。
1 |
|
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持(第11章已经讲解过逃逸分析技术),如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
1 |
|
我们也知道,由于 String 是一个不可变的类,对字符串的连接操作总是通过生成新的 String 对象来进行的,因此 Javac 编译器会对 String 连接做自动优化。在 JDK 1.5之前,会转化为 StringBuffer 对象的连续 append()操作,在 JDK 1.5及以后的版本中,会转化为 StringBuilder 对象的连续 append()操作.
锁粗化
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。
大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
连续的 append()方法就属于这类情况。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,以代码清单13-7为例,就是扩展到第一个 append()操作之前直至最后一个 append()操作之后,这样只需要加锁一次就可以了。
轻量级锁
首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。CAS
偏向锁
偏向锁也是 JDK 1.6中引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不做了。
偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
从1加到100
方法一
使用synchronized+对象wait()和notify()方法
1 |
|
方法二
在 thread1.join()
和 thread2.join()
方法中,join()
方法的作用是等待该线程终止。thread1.join()
方法的意思是:主线程等待 thread1
线程终止。当 thread1
线程终止后,主线程才会继续执行。
1 |
|