【并发基础】synchronized原理

548 阅读6分钟

1. 基本概念

1.1 synchronized

synchronized是Java中的关键字 ,是利用锁的机制来实现同步的。

1.2 锁机制的特性

  1. 互斥性

即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程中的协调机制,这样在同一时间只有一个线程对需同步的代码块(复合操作)进行访问。互斥性我们也往往称为操作的原子性。

  1. 可见性

必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作从而引起不一致。

1.3 对象锁与类锁

  1. 对象锁

在 Java 中,每个对象都会有一个 monitor 对象,这个对象其实就是 Java 对象的锁,通常会被称为“内置锁”或“对象锁”。类的对象可以有多个,所以每个对象有其独立的对象锁,互不干扰。

  1. 类锁

在 Java 中,针对每个类也有一个锁,可以称为“类锁”,类锁实际上是通过对象锁实现的,即类的 Class 对象锁。每个类只有一个 Class 对象,所以每个类只有一个类锁。

2. synchronized使用方法

2.1 修饰代码块

修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象。

// 修饰当前类(确保当前类的线程安全)
public void method1() {

    synchronized(this){
        ...
    }
    
}

// 修饰Object对象(确保被调用类的线程安全)
public void method2() {

    synchronized(object){
        ...
    }
    
}

2.2 修饰方法

synchronized修饰方法和修饰一个代码块类似,只是作用范围不一样,修饰代码块是大括号括起来的范围,而修饰方法范围是整个函数。

// 修饰普通方法
public synchronized void method1(){
    ...
}

// 修饰静态方法
public synchronized static void method2(){
    ...
}

2.3 修饰类

使用方法与修饰代码块类似,但在设置作用范围时使用类名

public class Demo {
   public void method() {
   
      synchronized(Demo.class) {
         ...
      }
      
   }
}

3. 实例讲解

3.1 业务场景

假设现在有个排号系统,一共有五台排号机器,一共有5000个号码,现在5台设备同时排号

业务场景

3.2 线程不同步情况

public class App {

    private static int tmp = 1;

    public static void main(String[] args) {
       App app = new App();
        // 模拟五个线程在访问
        for (int i = 0; i < 5; i++) {
            new Thread(app::normal).start();
        }

    }

    /**
     * 线程不安全情况下
     */
    public void normal() {
        while (tmp < 5000) {
            System.out.println(Thread.currentThread().getName() + " is running , tmp is " + tmp++);
        }
    }
}

运行结果会得到一些乱序的结果(根据硬件设备不同,每个人的结果也不同)

结果

原因是5个线程同时运行,假设1线程刚把tmp=100取出来,这时2,3线程正好又运行完了,那么此时tmp其实已经等于102了,但线程1还是输出了100,然后再tmp++,然后又刷新到了主内存,那么其他线程再去取的时候就会得到错误的值。

3.2 修饰代码块(阻塞状态,本质单线程)

通过修饰业务代码,确保线程安全

public class App {

    private static int tmp = 1;

    public static void main(String[] args) {
       App app = new App();
        // 模拟五个线程在访问
        for (int i = 0; i < 5; i++) {
            // 修饰代码块
            new Thread(app::block).start();
        }

    }

    /**
     * 修饰代码块
     */
    public void block() {
        synchronized (this) {
            while (tmp < 5000) {
                System.out.println(Thread.currentThread().getName() + " is running , tmp is " + tmp++);
            }
        }
    }
}

结果

3.3 修饰方法(阻塞状态,本质单线程)

public class App {

    private static int tmp = 1;

    public static void main(String[] args) {
       App app = new App();
        // 模拟五个线程在访问
        for (int i = 0; i < 5; i++) {
            // 修饰方法
            new Thread(app::method).start();
        }

    }

    /**
     * 修饰方法
     */
    public synchronized void method() {
        while (tmp < 5000) {
            System.out.println(Thread.currentThread().getName() + " is running , tmp is " + tmp++);
        }
    }
}

结果

3.4 修饰类(阻塞状态,本质单线程)

public class App {

    private static int tmp = 1;

    public static void main(String[] args) {
       App app = new App();
        // 模拟五个线程在访问
        for (int i = 0; i < 5; i++) {
            // 修饰类
            new Thread(app::clazz).start();
        }

    }

    /**
     * 修饰类
     */
    public void clazz() {
        synchronized (App.class) {
            while (tmp < 5000) {
                System.out.println(Thread.currentThread().getName() + " is running , tmp is " + tmp++);
            }
        }
    }
}

结果

3.5 线程阻塞与jconsole

线程阻塞通常是指一个线程在执行过程中暂停,以等待某个条件的触发。

通过上面的三种修饰方式可以看出,不论哪种方式实现线程安全,输出结果中永远只有一个线程在输出内容,而其他线程都在等待他运行完成才运行,这种情况就叫线程阻塞。

这里可以通过jconsole看到线程阻塞的情况,这里我添加两行代码,方便我可以通过jconsole调试

public class App {

    private static int tmp = 1;

    public static void main(String[] args) {
       App app = new App();
        // 模拟五个线程在访问
        for (int i = 0; i < 5; i++) {
            // 修饰代码块
            new Thread(app::block).start();
        }

    }

    /**
     * 修饰代码块(查看阻塞)
     */
    public void block2() {
        synchronized (this) {
            while (index <= MAX) {
                try {
                    TimeUnit.SECONDS.sleep(1);
                    System.out.println(Thread.currentThread().getName() + " is running , index is " + index++);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

Thread-0

Thread-1

Thread-2

Thread-3

Thread-4

可以看出,除了Thread-0一直在运行以外,其他的四个线程都在等待,那这样的情况本质上还是只有一个线程,没有模拟到实际的业务场景(这里注意,不论怎么改,在不引入新技术之前,总是线程阻塞的,多个线程修改同一个变量一次只能有一个线程在运行。)

3.6 修饰代码块(阻塞状态,多线程)

synchronized是利用锁的机制来实现同步的,那么在修饰代码块的时候,作用区域为this(当前对象)的时候,我们只创建了一个App对象,那么synchronized实质就是锁住了这一个对象里的这段代码,那么你几个线程过来访问这段代码,你就必须一个线程运行完了才能运行下一个。所以我们需要多个App对象才能实现多线程运行,更好的模拟实际业务场景。

public class App {

    private static int tmp = 1;

    public static void main(String[] args) {
        App app1 = new App();
        App app2 = new App();
        App app3 = new App();
        App app4 = new App();
        App app5 = new App();
        // 模拟五个线程在访问
        // 修饰代码块
        new Thread(app1::block).start();
        new Thread(app2::block).start();
        new Thread(app3::block).start();
        new Thread(app4::block).start();
        new Thread(app5::block).start();

    }

    /**
     * 修饰代码块
     */
    public void block() {
        synchronized (this) {
            while (tmp < 5000) {
                System.out.println(Thread.currentThread().getName() + " is running , tmp is " + tmp++);
            }
        }
    }
}

结果

会发现他又乱序了,但是没有多号、少号。这还是因为synchronized的机制只是一种锁,他只能确保一次只有一个线程能进入他的作用域,而每次那个线程来,这是synchronized作用域外部决定的。

3.7 修饰方法、修饰类

修饰方法的效果和修饰代码块的效果一样,因为我这里写法的原因,其实修饰方法和修饰代码块其实没什么区别,都是对一个方法内的代码进行加锁。

修饰类的话就将作用域扩大了,虽然实例化了5个App对象,但是这5个对象是一个类,那么他们就会公用这一个类的锁。

3.8 如何解决这个业务场景

本次内容主要介绍关于synchronized原理,如何实现并发顺序执行排号会在后面引入新的知识来实现。(我写出来后,这一小节会改为文章链接,没有改就是还没写)

4. 总结

synchronized是Java中的关键字 ,是利用锁的机制来实现同步的,他锁的本质是他能确保一次只有一个线程能进入他的作用域进行操作。

5. 项目地址(demo03)

github.com/MagicH666/J…