JAVA学习-多线程-sychronized关键字

为了尽可能全面,文字比较多,速读可只读非块引用部分

1.简介

synchronized 关键字解决的是多个线程之间访问资源的同步性,持有这把锁会把程序的并发变成序列化, synchronized 关键字可以保证被它修饰的⽅法或者代码块在任意时刻只能有⼀个线程执⾏,是一个悲观锁

注意:

1)虽然synchronized包含偏向锁、轻量级锁、重量级锁,但它们都属于悲观锁,这些锁虽然有的用到了cas,但这只是获得锁的方式不一样。

悲观锁与乐观锁的区别在于是否锁定资源,乐观锁通过cas、版本号的方式替代直接锁定资源的方式,并不是严格意义的锁。

2)构造方法不能使用 synchronized 关键字修饰。构造方法本身就属于线程安全的,不存在同步的构造方法一说。

在Java中,构造方法是线程安全的,主要有以下几个原因:

首先对象的创建是一个原子操作:在Java中,创建一个对象涉及到分配内存空间、初始化对象的各个字段等步骤,这些步骤是在构造方法中一次性完成的。因此,在多线程环境下,不会出现多个线程同时执行构造方法导致对象初始化不完整或冲突的情况。

其次对象的构造过程对于其他线程是不可见的:在Java中,对象的构造过程(包括构造方法的执行和初始化)是在一个线程内执行的,并且只有在构造方法执行完成后,对象才会被发布到其他线程中。这种机制确保了在对象构造过程中的状态对于其他线程是不可见的,避免了多个线程同时操作同一个对象导致的竞态条件。

再者构造方法中的局部变量是线程私有的:在构造方法中,局部变量是线程私有的,每个线程执行构造方法时都会有自己的一份局部变量副本,不会被其他线程访问到或修改。

需要注意的是,尽管构造方法本身是线程安全的,但在构造方法中调用的其他方法或访问的共享资源可能不是线程安全的。因此,在编写构造方法时,需要特别注意与其他线程共享的资源的使用安全性。

2. 几种不同的锁机制

在JDK 1.6之后,synchronized锁的实现经过了一些优化,引入了几种不同的锁机制,以提高性能和并发性。这些优化包括:

偏向锁(Biased Locking):偏向锁是JVM针对单线程对同步块进行优化的机制。当一个线程获取到锁后,JVM会将锁的标记设置为偏向锁,并将线程ID记录在锁记录中。这样,当该线程再次访问同一锁时,无需执行额外的同步操作,提供了更快的访问速度。

轻量级锁(Lightweight Locking):轻量级锁是在多个线程之间进行同步争夺时,进行的一种优化。当一个线程访问同步块时,JVM会将对象头部的一部分标记为"轻量级锁",并将线程ID记录在锁记录中。如果其他线程也想访问同步块,它们会自旋一段时间去等待锁的释放,而不是阻塞线程。

自旋锁(Spin Locking):自旋锁是在轻量级锁的基础上进一步优化的机制。当线程自旋(见名词解释)等待锁释放的时间较短时,可以避免线程上下文切换的开销,提高并发性。自旋锁通过在硬件级别上执行忙等待来实现,线程在获取锁之前会不断自旋检查锁是否被释放。

重量级锁(Heavyweight Locking):如果线程自旋一段时间后仍未获取锁,就会升级为重量级锁。重量级锁是通过操作系统的互斥量(Mutex)实现的,当线程无法获取锁时,会被阻塞挂起,直到锁被释放。

需要注意的是,上述锁的机制是由JVM自动进行切换和优化的,开发者无需显式地选择或指定哪种锁。JVM根据需要和场景自动选择最合适的机制,以提供最佳的性能和并发度。

JDK1.6 对锁的实现还引入了大量的其他优化,如适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。

适应性自旋锁:自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。JDK采用了更聪明的方式——适应性自旋,线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。


锁销除:锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。


锁粗化:将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。eg:创建StringBuffer,然后连续使用stringBuffer.append方法,append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

3. 三种主要使⽤⽅式

1)修饰代码块 

指定加锁对象,对给定对象/类加锁,进⼊同步代码块前要获得给定对象的锁。对对象加锁主要就是对object header进行修改记录锁信息。

synchronized(object) {
    ...
}

修饰代码块底层原理synchronized 修饰代码块的实现使⽤的是 monitorenter 和 monitorexit 指令。monitorenter 指令指向代码块的开始位置, monitorexit 指令则指明代码块的结束位置。

a.在执⾏ monitorenter 时,线程试图获取锁也就是获取对象监视器 monitor 的持有权,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。
​ b.在执⾏ monitorexit 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外⼀个线程释放为⽌。

注:在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitor实现的。每个对象中都内置了⼀个 ObjectMonitor 对象。

monitor里面存有锁计数器、owner(持有者)、 阻塞队列(阻塞未拿到锁) 与wait队列(无限睡眠需要notify唤醒)。是非公平锁,争抢的是monitor对象。

2)修饰非静态⽅法

作用于当前对象加锁,线程进⼊同步代码前要获得当前对象的锁,同一时刻只有一个线程能够访问同一对象中这些被修饰的方法。

synchronized method() {
    ...
}

3)修饰静态⽅法

给当前类加锁,会作⽤于类的所有对象实例 ,进⼊同步代码前要获得当前 class 的锁, 同一时刻只有一个线程可以访问该静态方法。

synchronized static method() {
    ...
}

如果一个线程 A 调用一个对象的非静态 synchronized 方法,而线程 B 需要调用这个对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前对象的锁。
 

修饰(非)静态方法底层原理:synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。本质也是对对象监视器 monitor 的获取。

---------------------------------------------------------------------------------------------------------------------------------

4.名词解释

1) 线程自旋

在Java中,线程自旋是指线程在尝试获取锁时,会反复检查锁状态,而不是立即进入阻塞等待状态。当一个线程尝试获取锁时,如果锁已经被其他线程占用,那么该线程会进行自旋等待,即反复检查锁状态,直到锁被释放或者超过一定时间或次数。

线程自旋的目的是为了避免线程切换带来的开销。线程切换需要保存当前线程的上下文,然后恢复另一个线程的上下文,这个过程需要耗费时间和资源。当线程竞争不激烈,锁被占用的时间很短时,使用自旋等待可以更快地获取到锁,减少线程切换的开销。

在Java中,自旋等待是通过在循环中反复检查锁状态实现的。一般情况下,自旋等待会在一定次数或一定时间之后转为阻塞等待,以避免无限循环浪费CPU资源。

需要注意的是,自旋等待适用于锁竞争不激烈、锁占用时间短的情况。如果锁占用时间较长,或者锁竞争激烈,自旋等待可能会长时间占用CPU资源,效果不佳。因此,在使用自旋等待时,需要根据具体的场景和需求进行调整。

2)线程安全

线程安全是指在多线程环境下,对共享数据的访问操作能够正确地进行,不会出现数据不一致或者损坏的情况。

在多线程编程中,如果多个线程同时对共享数据进行读写操作,而没有适当的同步机制,可能会导致数据竞争、数据冲突、数据不一致等问题。线程安全的代码能够避免这些问题的发生,保证程序的正确性和可靠性。

线程安全的代码具有以下特点:

  1. 原子性:对共享数据的操作是原子的,即不可被中断的整体操作。
  2. 可见性:当一个线程对共享数据进行了修改,其他线程能够立即看到这个修改。
  3. 有序性:线程执行的顺序与代码的执行顺序一致,不会发生重排序。

为了实现线程安全,可以采用以下方法:

  1. 使用同步机制:如synchronized关键字或Lock接口进行加锁操作,保证同一时刻只有一个线程能够访问共享数据。
  2. 使用原子类:如AtomicInteger、AtomicBoolean等,利用原子操作来进行对共享数据的操作,保证原子性。
  3. 使用线程安全的集合类:如ConcurrentHashMap、CopyOnWriteArrayList等,这些集合类内部实现了线程安全的操作。
  4. 使用不可变对象:不可变对象的状态不能被修改,因此可以保证多个线程同时访问时不会出现数据冲突。
  5. 使用线程局部变量:每个线程拥有自己的数据副本,避免了线程间共享数据的竞争。

需要注意的是,保证代码的线程安全性可能会增加一定的开销,因此在实际编程中需要根据具体情况权衡性能和线程安全性的需求。

3)悲观锁与乐观锁

悲观锁和乐观锁是两种不同的并发控制方式,用于解决多线程环境下对共享资源的访问冲突问题。

  1. 悲观锁:
    悲观锁的基本思想是,对于共享资源的访问,每次访问时都假设会发生冲突,因此在访问前先使用互斥锁将资源加锁,确保同一时刻只有一个线程能够访问。悲观锁常用的实现方式是通过使用synchronized关键字或ReentrantLock类进行加锁。

  2. 乐观锁:
    乐观锁的基本思想是,对于共享资源的访问,每次访问时都认为不会发生冲突,并不加锁,而是通过比较和替换的方式来保证数据的一致性。乐观锁的实现通常是利用一种称为CAS(比较和交换)的机制,通过无锁算法实现的。在Java中,AtomicInteger、AtomicLong等原子类就是使用乐观锁的实现。

悲观锁适合在并发写操作较多的情况下使用,它假设冲突频繁发生,并加锁保证了数据的一致性,但可能导致较高的线程竞争和性能开销。

乐观锁适合在并发读操作较多、冲突发生较少的情况下使用,它假设冲突较少发生,并使用无锁算法保证数据的一致性,减少了线程阻塞和性能开销。

需要根据具体的场景和需求,选择适合的锁机制来控制并发访问的一致性和性能。

4)硬件忙等待

硬件忙等待指的是在多线程或多进程编程中,某个线程或进程在等待某个条件满足时,采用主动轮询的方式不断查看条件是否满足,而不进行睡眠或阻塞等待的操作。这种等待方式会持续消耗CPU资源,因为它一直在主动检查条件是否满足。

硬件忙等待通常用在临界区的竞态条件上,当一个线程或进程在临界区的共享资源被其他线程或进程占用时,它会持续进行轮询,直到共享资源被释放。这种方式可以减少竞态条件的发生,但会造成CPU资源的浪费。

硬件忙等待的方式相对简单,逻辑清晰,但对系统的性能和资源利用率有一定影响。因此,在实际编程中,需要权衡使用硬件忙等待的场景和条件,确保在资源有限的情况下不过度消耗CPU资源。

为了避免硬件忙等待带来的性能问题,可以采用更高级别的同步机制,如互斥锁、条件变量、信号量等,使线程或进程能够在等待条件满足时进入阻塞状态,从而释放CPU资源给其他线程或进程使用。

5)Mark Word(对象头)

Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的 锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节, 也就是32bit)

拓展:并发Synchronized原理_synchronized contentlist entrylist_harryptter的博客-CSDN博客

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值