关于synchronized的介绍

Posted 忘忧记

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了关于synchronized的介绍相关的知识,希望对你有一定的参考价值。

文章目录


前言

我们先来看一下什么是synchronized,我做出了以下解释,大家可以看一下.
1.synchronized 是Java中的关键字,用于实现同步机制,确保在多线程环境下对共享资源的访问的安全性。
2.synchronized 可以被用来修饰方法或代码块,其作用是在同一时刻,只能有一个线程执行被 synchronized 修饰的代码,其他线程必须等待锁的释放才能继续执行。

3.在使用 synchronized 时,需要指定锁对象,锁对象可以是任意对象,只有持有同一把锁的线程才能访问被 synchronized 修饰的代码块或方法。

4.当一个线程进入被 synchronized 修饰的代码块或方法时,它会尝试获取锁对象的锁,如果锁没有被其他线程持有,那么该线程会获得锁并继续执行代码,当线程退出 synchronized 修饰的代码块或方法时,它会释放锁,以便其他线程可以获取锁并执行代码。如果锁被其他线程持有,则该线程将进入阻塞状态,等待获取锁。


一.synchronized的特性

我这里开始列出synchronized的特性如下:

原子性:synchronized能够保证被它修饰的代码块或方法的执行是原子性的,即在同一时刻只能有一个线程进入被synchronized修饰的代码块或方法。

可重入性:synchronized是可重入的,即在一个线程已经持有某个对象的锁时,它可以再次进入一个synchronized块或方法。

可见性:synchronized能够保证共享变量在多线程间的可见性,即一个线程修改了共享变量的值,另一个线程能够立即看到该变量的最新值。

互斥性:synchronized能够保证同一时刻只有一个线程能够进入被synchronized修饰的代码块或方法,从而保证线程之间的互斥性。

有序性:synchronized能够保证同一时刻只有一个线程能够执行被synchronized修饰的代码块或方法,从而保证线程之间的有序性。


二.synchronized的使用

我先给出一个综合的代码实例,来基础的展示一下synchronized的使用,代码如下:
这个代码我们就是用加锁操作,来保证俩个线程对count++的操作是原子性的.

public class SynchronizedExample 

    private int count = 0;

    public synchronized void increment() 
        count++;
    

    public static void main(String[] args) throws InterruptedException 
        SynchronizedExample example = new SynchronizedExample();

        // 创建两个线程来同时增加计数器的值
        Thread thread1 = new Thread(() -> 
            for (int i = 0; i < 100000; i++) 
                example.increment();
            
        );

        Thread thread2 = new Thread(() -> 
            for (int i = 0; i < 100000; i++) 
                example.increment();
            
        );

        thread1.start();
        thread2.start();

        // 等待两个线程执行完毕
        thread1.join();
        thread2.join();

        System.out.println("Count: " + example.count);
    


2.1 同步方法

public class Counter 
    private int count;

    public synchronized void increment() 
        count++;
    

    public synchronized void decrement() 
        count--;
    

    public synchronized int getCount() 
        return count;
    


2.2 同步代码块

public class Example 
    private Object lock = new Object();
    private int count = 0;

    public void increment() 
        synchronized (lock) 
            count++;
        
    

    public void decrement() 
        synchronized (lock) 
            count--;
        
    

    public int getCount() 
        synchronized (lock) 
            return count;
        
    


2.3 静态同步方法

public class Example 
    private static int count;

    public static synchronized void increment() 
        count++;
    

    public static synchronized void decrement() 
        count--;
    

    public static synchronized int getCount() 
        return count;
    


2.4 同步代码块和volatile关键字

public class Example 
    private volatile int count = 0;

    public void increment() 
        synchronized (this) 
            count++;
        
    

    public void decrement() 
        synchronized (this) 
            count--;
        
    

    public int getCount() 
        return count;
    


这里我简单的介绍了synchronized的使用方法,但synchronized并不是一个单独的个体,可以根据不同场景,综合使用.


三.synchronized的锁机制

synchronized的锁机制,我先简单的阐述一下,什么是锁机制.
锁机制通过对共享资源的加锁来保证同一时刻只有一个线程可以访问该资源,其他线程必须等待锁的释放后才能访问。通过锁机制,可以有效地避免竞态条件的出现,从而保证程序的正确性和稳定性。

对于synchronized的来说,synchronized使用的是对象级别的锁机制,即每个对象都有一个与之关联的锁(也称为监视器)。在synchronized关键字加锁的代码块执行期间,该对象的锁被获取,其他线程无法访问该对象的synchronized代码块,只能等待当前线程执行完毕释放锁之后才能访问。

3.1 synchronized是什么锁机制

synchronized是Java中的一种锁机制,用于控制多个线程对共享资源的访问。synchronized可以用来修饰方法或代码块,在修饰方法时,锁住的是整个方法,而在修饰代码块时,锁住的是代码块中的对象。

synchronized锁机制的实现基于Java中的内置锁,也称为监视器锁。每个Java对象都有一个内置锁,可以用来同步访问该对象的代码。当一个线程获取了一个对象的内置锁后,其他线程必须等待该线程释放锁才能获取该对象的锁。

3.2 synchronized的工作过程

结合我们学习的锁策略,我们对synchronized进行以下总结:

  1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
  2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
  3. 实现轻量级锁的时候大概率用到的自旋锁策略
  4. 是一种不公平锁
  5. 是一种可重入锁
  6. 不是读写锁

加锁过程

这上面解释了,synchronized的加锁过程,其中自旋锁和重量级锁,我们都知道是什么,但对偏向锁,好像没有太多的概念,我这里先解释一下什么是偏向锁.
这里还会来举一个生活中的小例子.女神和男神的故事

偏向锁,只是先让线程针对锁,有个标记(做个标记很快的,非常轻量)
(偏向锁,只是先让线程针对锁,有个标记(做个标记很快的,非常轻量)

如果整个代码执行过程中,都没有遇到别的线程和我竞争这个锁,此时就不用真加锁了!!!
如果整个代码执行过程中,都没有遇到别的线程和我竞争这个锁~~此时就不用真加锁了!

但是一旦,要是有别的线程尝试来竞争这个锁!!!于是偏向锁就立即升级成真的锁(轻量级锁),此时别的线程只能等待
但是一旦,要是有别的线程尝试来竞争这个锁!于是偏向锁就立即升级成真的锁(轻量级锁),此时别的线程只能等待.
既保证了效率,又保证了线程安全!!
既保证了效率,又保证了线程安全!!

如果还没有明白,我这里再举一个实际的代码例子

public class MyObject 
    private int x;
    private int y;

    public synchronized void increment() 
        x++;
        y++;
    

如果在多线程环境下,多个线程同时对 obj 进行操作,那么就可能会发生竞争条件,从而导致结果不可预测。此时,我们可以使用偏向锁来优化。

偏向锁的实现方式是,如果一个线程获取了该对象的锁,那么该对象就会被标记为偏向模式,并且该线程的 ID 会被记录在对象头中。接下来,如果该线程再次请求锁,就无需进行竞争,直接获取锁即可。这样就可以避免多线程竞争的开销。

例如,我们可以在一个单线程环境下进行如下操作:

MyObject obj = new MyObject();
obj.increment(); // 偏向锁对象
obj.increment(); // 无需竞争,直接获取锁

在第二次调用 increment() 方法时,由于只有一个线程在操作该对象,因此可以直接获取锁,无需进行竞争。这样就可以提高程序的性能

然后我们继续来说明synchronized工作过程的下一个阶段,就是进入偏向锁,之后,随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
此处的轻量级锁就是通过 CAS 来实现.

通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
如果更新成功, 则认为加锁成功
如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).

最后,如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex .
执行加锁操作, 先进入内核态.
在内核态判定当前锁是否已经被占用
如果该锁没有占用, 则加锁成功, 并切换回用户态.
如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒
这个线程, 尝试重新获取锁.

四. 其他的优化过程

锁消除

简单来说就是非必要不加锁
举一个简单的例子,我们经常使用的StringBuilder和StringBuffer
一般来说,我们说StirngBuffer是线程安全的,为什么呢?大家可以看一下源码

这里实际上对他们都进行了加锁,但说实话,我们在使用他们的时候,不一定都是多线程的一个环境,如果是单线程使用,就不涉及线程安全的问题了,但这个但是这个synchronized是不是也写了?,但其实在编译阶段,没有被真正的去编译,这样的操作就是锁消除,可见编译器的优化,很重要,它还是比我们聪明的.
最后来总结一下:
锁消除是指在编译期间,由于分析程序的执行情况发现一些同步操作不会出现竞争,编译器会自动消除这些不必要的同步操作,从而提高程序的执行效率。

锁粗化

画一个小小的图,大家来感受一下:


大家如果还是模模糊糊,我来一个实际的代码,大家来体验一下:
例如,下面的代码中,循环内部的加锁和解锁操作会重复执行多次


for (int i = 0; i < 1000; i++) 
    lock();
    // do something
    unlock();


如果将这些加锁解锁操作合并为一次加锁解锁操作,代码如下所示:

lock();
for (int i = 0; i < 1000; i++) 
    // do something

unlock();

这样做可以减少加锁解锁的次数,从而提高程序的执行效率。

最后来小小的总结一下:
锁粗化是一种优化技术,它的目的是将多个连续的加锁、解锁操作合并成一个更大的锁操作,从而减少加锁解锁的次数,减小锁的竞争。

363如何用一句话介绍synchronize的内涵

内涵与表象

关于synchronize,一个非常通俗易懂,很容易记住的解释是:

Java语言的关键字,当它用来修饰一个方法或者一个代码块的时候,能够保证在同一时刻最多只有一个线程执行该段代码。

这个解释很好,它非常直观的告诉我们使用synchronize会带来什么效果。
然而,也正因为如此,这个解释太过停留在了表面,就像给一款洗衣机做广告,广告中说这款自动式洗衣机可以一键洗衣一样,如果只是这样说,那根本无法展示这台洗衣机有什么与众不同的地方,因为市面上可以一键式操作的洗衣机太多了,必须向客户抛出问题,这款洗衣机是如何一键式完成整个洗衣流程的、为什么这款洗衣机洗的比别人干净,然后贴上各种高科技高逼格的图片、播放各种酷炫的动画视频,这样,客户才了解了这款洗衣机的内涵,才有可能对这款洗衣机动心。

回到synchronize,开头的解释告诉我们,synchronize可以“保证在同一时刻最多只有一个线程执行该段代码”,那么,我们就不得不去想:

  • synchronize是如何“保证在同一时刻最多只有一个线程执行该段代码”的?
  • “保证在同一时刻最多只有一个线程执行该段代码”,这又会带来什么意义?

太长不看版:

Java中的synchronize,通过使用内置锁,来实现对变量的同步操作,进而实现了对变量操作的原子性和其他线程对变量的可见性,从而确保了并发情况下的线程安全。

基本用法

首先还是要刷一把代码,我会用一个简单的例子演示如何使用synchronize,并对其进行测试。如果你已经了解了synchronize的用法,可以快速略读这一小节。

假设我们要给一个处理器加入计数器,每次调用时给计数器加一,为方便扩展,我们定义了如下接口(本文的示例代码,可到Github下载):
CountingProcessor:

public interface CountingProcessor 
    void process();
    long getCount();

不使用同步机制,我们写出了第一个版本:
UnThreadSafeCountingProcessor:

public class UnThreadSafeCountingProcessor implements CountingProcessor 

    private long count = 0;

    public void process() 
        doProcess();
        count ++;
    

    public long getCount() 
        return count;
    

    private void doProcess() 
    

这个版本自然是线程不安全的,原因就是之前在《如何写出线程不安全的代码》里提到的,count++是一个“读取-修改-写入”三个动作的操作序列。要想验证这个类是线程不安全的,非常简单,写个测试类测一下就知道了(用例写的比较粗糙,后面再来谈谈如何测试并发程序):
SynchronizeProcessTest:

public class SynchronizeProcessTest 

    public static final int LOOP_TIME = 1000 * 10000;

    @Test
    public void test_UnThreadSafeCountingProcessor() 
        CountingProcessor countingProcessor = new UnThreadSafeCountingProcessor();
        runTask(countingProcessor);
    

    private void runTask(CountingProcessor processor) 
        Thread thread1 = new Thread(new ProcessTask(processor, LOOP_TIME), "thread-1");
        Thread thread2 = new Thread(new ProcessTask(processor, LOOP_TIME), "thread-2");
        thread1.start();
        thread2.start();
        // wait unit all the threads have finished
        while(thread1.isAlive() || thread2.isAlive()) 
    

其中的ProcessTask如下所示:

public class ProcessTask implements Runnable 

    private static Logger logger = LoggerFactory.getLogger(ProcessTask.class);

    private CountingProcessor countingProcessor;
    private long loopTime;

    public ProcessTask(CountingProcessor countingProcessor, long loopTime) 
        this.countingProcessor = countingProcessor;
        this.loopTime = loopTime;
    

    public void run() 
        int i = 0;
        while (i < loopTime) 
            countingProcessor.process();
            i ++;
        
        logger.info("Finally, the count is ", countingProcessor.getCount());
    

在ProcessTask里,我们不断循环执行process()方法,让计数器不断递增。然后在测试类中,我们创建了两个线程,分别指定ProcessTask的循环次数为一千万次,最后查看日志打印,如果程序时线程安全的,那么当最后一个线程结束时,打印的计数器应该是两千万,接着我们运行测试用例:

从运行结果可以看出来,在经历了两千万次调用后,count的值是10469363,少计算了快一半。

要让我们这个计数器变得线程安全,有很多种方法,这里只介绍使用synchronize的两种方法,第一种,我们可以给整个函数加上synchronize修饰符:
SynchronizeMethodCountingProcessor:

	...  
    public synchronized void process() 
        doProcess();
        count++;
    
	...  

这样子固然可以解决问题,但是我们其实没必要对整个函数都进行同步,这样会影响程序的吞吐量,我们只需要在计数器加一的过程进行同步就好了,由此我们写出第二种synchronize的版本,也就是synchronize代码块:
SynchronizeBlockCountingProcessor:

	...
    public void process() 
        doProcess();
        synchronized (this) 
            count ++;
        
    
	...

同样,我们给这两个类增加两个测试用例,借助前面良好的程序设计,我们这两个用例得以写的非常简洁:
SynchronizeProcessTest:

	...

    @Test
    public void test_SynchronizeMethodCountingProcessor() 
        CountingProcessor countingProcessor = new SynchronizeMethodCountingProcessor();
        runTask(countingProcessor);
    

    @Test
    public void test_SynchronizeBlockCountingProcessor() 
        CountingProcessor countingProcessor = new SynchronizeBlockCountingProcessor();
        runTask(countingProcessor);
    

	...

执行用例:

 

可以看到,使用synchronize改造后的版本,最后count都等于两千万,说明它们是线程安全的。

原子性

上面的例子,展示了synchronize的一个作用:确保了操作的原子性
原先count++是三个动作,其他线程可以在这三个操作之间对count变量进行修改,而在使用了synchronize之后,这三个动作就变成一个不可拆分、一气呵成的动作,不必担心在这个操作的过程中会有其他线程进行干扰,这就是原子性。
原子操作是线程安全的,这其实也是我们经常使用synchronize来实现线程安全的原因。

可见性

上面我们提到了synchronize的第一个作用,确保原子性,这其实是从使用synchronize的线程的角度来讲的,而如果我们从其他线程的角度来看,那么synchronize则是实现了可见性
可见性的意思是变量的修改可以被其他线程观察到,在上面计数器的例子中,由于一次只有一个线程可以执行count++,抢不到锁的线程,必须等抢到锁的线程更新完count之后,才可以去执行count++,而这个时候,count也已经完成了更新,新的锁持有者,可以看到更新后的count,而不至于拿着旧的count值去进行计算,这就是可见性。

提起可见性,我们就不得不提到volatile关键字,volatile实现了比synchronize更轻量级的同步机制,或者说,加锁机制既确保了可见性,有确保了原子性,而volatile只能保证可见性。

Locking can guarantee both visibility and atomicity; volatile variables can only guarantee visibility. —— 《Java并发编程实践

关于volatile关键字,我们后面再单独研究,这里就不深入探讨了。

下面,让我们来探讨开头提的问题,synchronize是如何“保证在同一时刻最多只有一个线程执行该段代码”的?

内置锁

关于synchronize,我们经常使用的隐喻就是锁,首先进入的线程,拿到了锁的唯一一把钥匙,至于其他线程,就只能阻塞(Blocked);等到线程走出synchronize之后,会把锁释放掉,也就是把钥匙扔出去,下一个拿到钥匙的线程,就可以结束阻塞状态,继续运行。
但是锁从哪来呢?随随便便抓起一个东西就可以作为锁么?
还真是这样,Java中每一个对象都有一个与之关联的锁,称为内置锁

Every object has an intrinsic lock associated with it. ——  The Java™ Tutorials

当我们使用synchronize修饰非静态方法时,用的是调用该方法的实例的内置锁,也就是this;
当我们使用synchronize修饰静态方法时,用的是调用该方法的所在的类对象的内置锁;
更多时候,我们使用的是synchronize代码块,我们经常用的是synchronize(this),也就是把对象实例作为锁。

同一时间进入同一个锁的线程只有一个,如果我们希望有多个线程可以同时进入多个加了锁的方法,那只靠一个this锁肯定是不够的,那怎么办?一点都不担心,还记得上面说的吗,Java中每个对象都是锁,想用的时候new一个Object就好了:

public class MsLunch 
    private long c1 = 0;
    private long c2 = 0;
    private Object lock1 = new Object();
    private Object lock2 = new Object();

    public void inc1() 
        synchronized(lock1) 
            c1++;
        
    

    public void inc2() 
        synchronized(lock2) 
            c2++;
        
    
 

Java中只能使用对象作为锁吗,当然不是的,我们还可以自己打造一把锁,也就是显示锁,比如这样:

    Lock lock = ...;
      if (lock.tryLock()) 
          try 
              // manipulate protected state
           finally 
              lock.unlock();
          
       else 
          // perform alternative actions
       

至于显示锁具体怎么用和它的原理,以及Java中其他奇奇怪怪的锁,我们也不在这里细究,后面再和大家一块探讨。

重入

最后再来看看这个代码有什么问题:

public class Widget 
	public synchronized void doSomething() 
		...
	


public class LoggingWidget extends Widget 
	public synchronized void doSomething() 
		System.out.println(toString() + ": calling doSomething");
		super.doSomething();
	

分析:
前面提到,synchronized修饰非静态方法时,用的是调用该方法的对象实例作为锁,所以上面的代码中,调用LoggingWidget的doSomething时,拿到了实例的锁的钥匙,接着再去调用父类的doSomething方法,父类的方法同样被synchronized修饰,此时钥匙已经被拿走了而且还没释放,所以阻塞,而阻塞导致LoggingWidget的doSomething方法无法执行完成,因而锁一直不会被释放,所以,死锁了???

当然不是,上面的理解错在了弄错了锁的持有者锁的持有者是“线程”,而不是“调用”,线程在进入LoggingWidget的doSomething方法时,已经拿到this对象内置锁的钥匙了,下次再碰到同一把锁,自然是用同一把钥匙去打开它就可以了。这就是内置锁的可重入性(Reentrancy)。

既然锁是可重入的,那么也就意味着,JVM不能简单的在线程执行完synchronized方法或者synchronized代码块时就释放锁,因为线程可能同时“重入”了很多道锁,事实上,JVM是借助锁上的计数器来判断是否可以释放锁的:

Reentrancy is implemented by associating with each lock an acquisition  count  and an owning thread. When the count is zero, the lock is considered unheld. When a thread acquires a previously unheld lock, the JVM records the owner and sets the acquisition count to one. If that same thread acquires the lock again, the count is incremented, and when the owning thread exits the synchronized block, the count is decremented. When the count reaches zero, the lock is released. —— 《Java并发编程实践》

如果将含有synchronized代码块的代码编译出来的class文件,使用javap进行反汇编,你可以看到会有两条指令: monitorenter和monitorexit,这两条指令做的也就是上面说的那些事,有兴趣的同学可以研究一下。

总结

这篇文章主要对Java中的synchronized做了一些研究,总结一下:

  1. Java中每个对象都有一个内置锁
  2. 与内置锁相对的是显示锁,使用显示锁需要手动创建Lock对象,而内置锁则是所有对象自带的。
  3. synchronized使用对象自带的内置锁来进行加锁,从而保证在同一时刻最多只有一个线程执行代码。
  4. 所有的加锁行为,都可以带来两个保障——原子性可见性。其中,原子性是相对锁所在的线程的角度而言,而可见性则是相对其他线程而言。
  5. 锁的持有者是“线程”,而不是“调用”,这也是锁的为什么是可重入的原因。

如何向一个新手介绍synchronized的表象?

Java语言的关键字,当它用来修饰一个方法或者一个代码块的时候,能够保证在同一时刻最多只有一个线程执行该段代码。

如何在一个老司机面前装逼格?

Java中的synchronize,通过使用内置锁,来实现对变量的同步操作,进而实现了对变量操作的原子性和其他线程对变量的可见性,从而确保了并发情况下的线程安全。

后记

难道synchronize就是这样了?自然不是,只要你继续研究,肯定还会提出很多问题。我先提一个:

  • 抢不到锁而进入阻塞状态的线程,怎么知道锁什么时候会被释放?

要想弄清楚synchronize的原理,最直截了当的方式自然是看源码,当然这也是难度最大的,毕竟JVM源码都是C语言;另一种方法就是不断向自己提问,然后不断搜索资料,解答自己提出的问题。

看似简单的知识,深究起来,往往没那么简单。
只有学会提问,才能透过表象,看清原理;理解了原理,遇到Bug才能不慌。

参考

以上是关于关于synchronized的介绍的主要内容,如果未能解决你的问题,请参考以下文章

关于JAVA里的加锁synchronized

363如何用一句话介绍synchronize的内涵

关于JAVA中的Synchronization和interface误用

#yyds干货盘点# Java | 关于synchronized相关理解

关于Synchronized

关于Synchronized