并发基础知识之synchronized关键字

news/2024/5/17 15:34:31 标签: 并发, synchronized, 基础, 死锁, 原子性

上一篇文章我总结了一下线程的创建方法以及线程的一些属性,同时还讲了线程的共享以及带来的原子性和内存可见性的问题。这篇文章就讲讲怎么用synchronized关键字解决那两个问题。


synchronized_3">1.synchronized的用法和基本原理

synchronized可以修饰实例方法,静态方法和代码块。

上篇我们讲了一个counter计数器的问题,由于counter++不是一个原子操作,所以在多线程中,输出的结果往往不是我们所预期的,现在我们看看怎么分别用着三种方式解决这个问题。

(1)修饰实例方法

public class Counter {
    private int counter = 0;
    public synchronized void incr() {
        counter++;
    }
    
    public synchronized int getCounter() {
        return counter;
    }
}

Counter类是一个简单的计数器类,里面有两个方法,一个让计数加1,一个返回计数的值,都加了synchronized 修饰,这样方法内的代码就是原子操作,当多个线程更新同一个Counter对象的时候,也不会有问题。

public class CounterThread extends Thread {
    private Counter counter;
    public CounterThread(Counter counter) {
        this.counter = counter;
    }
    @Override
    public void run() {
        for(int i = 0; i < 1000; i++) {
            counter.incr();
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        int num = 1000;
        Counter counter = new Counter();
        Thread[] threads = new Thread[num];
        for(int i = 0; i < num; i++) {
            threads[i] = new CounterThread(counter);
            threads[i].start();
        }
        for(int i = 0; i < num; i++) {
            threads[i].join();
        }
        System.out.println(counter.getCounter());
    }
}

不论运行多少次,都是输出1000*1000。

那么这里的synchronized到底起了什么作用呢?表面上看,是让同时只能有一个线程执行实例方法,但其实这是有条件的,那就是同一个对象。是的,如果多个线程访问同一个对象的实例方法,那么synchronized就会让线程按顺序来执行,如果是不同对象,那么多个线程时可以同时访问同一个synchronized方法的,只要它们访问的对象是不同的即可。

比如

Counter c1 = new Counter();
Counter c2 = new Counter();
Thread t1 = new CounterThread(c1);
Thread t2 = new CounterThread(c2);
t1.start();
t2.start();

这里,t1和t2两个线程时可以同时执行Counter的incr方法的,因为它们访问的是不同的Counter对象。

相反,如果访问的是同一个对象的synchronized方法,那么即使是不同的synchronized方法,也需要等待的。比如Counter类中的getCounter和incr,对同一个Counter对象,一个线程执行getCounter方法,一个线程执行incr方法,虽然是不同的方法,但它们还是不能同时执行,会被synchronized同步顺序执行。

所以,synchronized实际保护的是同一个对象的方法调用,确保同时只要一个线程执行。再具体来说,synchronized保护的是当前的实例对象,即this,this对象有一个锁和一个等待队列,锁只能被一个线程拥有,其他线程要获得同样的锁需要等待。执行synchronized修饰的实例方法的大致过程如下:

1.尝试获得锁,如果能获得,执行下一步,否则加入等待队列,阻塞并等待唤醒,线程状态变成BLOCKED。
2.执行实例方法内的代码。
3.释放锁,如果等待队列里有等待的线程,则取一个唤醒,如果有多个,则随机,不保证公平性。

synchronized实际的执行过程比这复杂得多,但我们可以这样简单的理解。

此外还要说明的是,synchronized方法不能防止非synchronized方法被同时执行,比如给Counter类加一个非synchronized方法,则该方法可以和incr方法一起执行,这通常会出现意想不到的结果,所以,对于一个变量来说,一般给该访问该变量的所有方法加上synchronized

(2)修饰静态方法

public class StaticCounter {
    private static int counter = 0;
    public static synchronized void incr() {
        counter++;
    }
    
    public static synchronized int getCounter() {
        return counter;
    }
}

前面我们说,synchronized修饰实例方法,保护的是当前实例对象this,那么修饰静态方法,保护的是那个对象呢?是类对象。对上面的例子也就是StaticCounter.class,每个对象都有一个锁和一个等待队列,类对象也不例外。

因为synchronized静态方法和synchronized实例方法保护的是不同的对象,所以不同的两个线程,可以一个执行synchronized静态方法,一个执行synchronized实例方法。

(3)修饰代码块

public class Counter {
    private int counter = 0;
    public void incr() {
        synchronized(this) {
            counter++;
        }
    }
    
    public int getCounter() {
        synchronized(this) {
            return counter;
        }
    }
}

synchronized括号里面就是保护的对象。对于实例方法,就是this。对于前面的StaticCounter类,等价代码如下

public class StaticCounter {
    private static int counter = 0;
    public static void incr() {
                synchronized(StaticCounter .class) {
                        counter++;
                }
    }
    
    public static int getCounter() {
        synchronized(StaticCounter .class) {
                       return counter;
                }
    }
}

synchronized同步的对象可以是任意对象,任意对象都有一个锁和一个等待队列,或者说,任何对象都可以成为锁对象。

比如Counter的等价代码还可以如下

public class Counter {
    private int counter = 0;
    private Object lock = new Object();
    public void incr() {
        synchronized(lock) {
            counter++;
        }
    }
    
    public int getCounter() {
        synchronized(lock) {
            return counter;
        }
    }
}

synchronized_152">2.进一步了解synchronized

介绍了synchronized的基本用法和原理之后,现在从以下三个方面进一步介绍

  • 可重入性
  • 内存可见性
  • 死锁

(1)可重入性
可重入性是指如果一个线程获得一个锁之后,在调用其他需要同样锁的代码时,可以直接调用。比如在一个synchronized实例方法内,可以直接调用其他synchronized实例方法。

可重入是通过记录锁的持有线程和持有数量来实现的。当调用synchronized保护的代码时,检查对象是否被锁,如果是,再检查是否是被当前线程持有,如果是,增加持有数量,如果不是,则线程加入等待队列,当释放锁时,减少持有数量,当持有数量变为0的时候,才释放整个锁。

(2)内存可见性

synchronized除了可以保证原子性之外,还能保证内存可见性。在释放锁的时候,所有写入都会写入内存,而获得锁后,都会从内存中读最新数据。

但如果只是为了保证内存可见性,使用synchronized成本有点高,我们可以使用volatile关键字修饰变量,比如上篇文章中的内存可见性问题,代码可以该成如下,就可以解决内存可见性问题。

public class VisibilityDemo {
    private static volatile boolean shutdown = false;
    static class HelloThread extends Thread {
        @Override
        public void run() {
            while(!shutdown) {
                System.out.println("1");
            }
            System.out.println("exit hello");
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        new HelloThread().start();
        Thread.sleep(1000);
        shutdown = true;
        System.out.println("exit main");
    }
}

可以看到使用volatile修饰了shutdown变量。加入volatile后,java会在操作对应变量时插入特殊的指令,保证读写到内存最新值,而非缓存的值。

(3)死锁
使用synchronized或者其他锁,可能会产生死锁,比如,有a,b两线程,a线程持有锁A,等待锁B,b线程持有锁B,等待锁A,这样a,b就互相等待,永远不会执行。

public class DeadLockDemo {
    private static Object lock1 = new Object();
    private static Object lock2 = new Object();
    private static void startThreadA() {
        Thread thread1 = new Thread() {
            @Override
            public void run() {
                synchronized (lock1) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (lock2) {
                    }
                }
            }
        };
        thread1.start();
    }
    
    private static void startThreadB() {
        Thread thread2 = new Thread() {
            @Override
            public void run() {
                synchronized (lock2) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (lock1) {
                    }
                }
            }
        };
        thread2.start();
    }
    
    public static void main(String[] args) {
        startThreadA();
        startThreadB();
    }
}

应该尽量避免在持有一个锁的同时去申请另外一个锁,如果确实需要多个锁,所有代码应该按照相同的顺序去申请锁。对于上面的例子,可以约定都先申请lock1,再申请lock2。


往期文章

并发基础知识之线程的基本概念
并发基础知识之线程间的协作


http://www.niftyadmin.cn/n/1236402.html

相关文章

深入理解Jvm--Java静态分配和动态分配完全解析

jvm中分配Dispatch的概念 分派是针对方法而言的&#xff0c;指的是方法确定的过程&#xff0c;通常发生在方法调用的过程中。分派根据方法选择的发生时机可以分为静态分派和动态分派&#xff0c;其中对于动态分派&#xff0c;根据宗量种数又可以分为单分派和多分派。实际上指的…

并发基础知识之线程间的协作

多线程除了我们前面讲的竞争&#xff0c;其实还有协作。就像我们人一样&#xff0c;不但要竞争&#xff0c;也要学会合作&#xff0c;这样才能进步。这篇文章我们就讲讲多线程协作的基本机制wait/notify。同时使用多线程实现生产者/消费者模式。 1.协作的场景 多线程协作的场景…

深入理解jvm--Java类的初始化及主动引用和被动引用完全解析

初始化 类初始化阶段是类加载过程的最后一步&#xff0c;前面的类加载过程中&#xff0c;除了在加载阶段用户应用程序可以通过自定义类加载器参与之外&#xff0c;其余动作完全由虚拟机主导和控制。 到了初始化阶段&#xff0c;才真正开始执行类中定义的Java程序代码&#xff…

(一)Struts2实现第一个案例

小小的一个HelloWorld&#xff0c;包含的意义也是巨大的&#xff0c;理解了一个HelloWorld&#xff0c;对于后续学习Struts2更有帮助。 1.案例准备 首先得去官网下载必须的资源&#xff0c;推荐下载Full Distribution&#xff0c;因为它包含所有的东西&#xff0c;包括源代码这…

Java并发编程--深入理解volatile关键字

前言 一个月以前就准备写篇关于volatile关键字的博客&#xff0c;一直没有动笔&#xff0c;期间看了大量的文章&#xff0c;发现一个小小volatile关键字竟然涉及JMM&#xff08;Java memory model&#xff09;&#xff0c;JVM&#xff08;Java virtual machine&#xff09;&am…

(二)Struts2进阶之Struts2的常规配置

Struts2的默认配置文件是struts.xml&#xff0c;它的最大作用就是配置Action和请求之间的对应关系&#xff0c;并配置逻辑视图名和物理视图资源之间的对应关系&#xff0c;除此之外&#xff0c;struts.xml文件还可以配置Bean、常量以及导入其他配置文件。 struts.xml是联系整个…

(三)struts2进阶之实现Action

Action是Struts2应用的核心&#xff0c;用于处理用户的请求&#xff0c;因此Action也被称为业务控制器。每个Action都是一个工作单元&#xff0c;而Struts2框架则负责将用户的请求与相应的Action匹配。匹配成功则使用相应的Action处理用户的请求&#xff0c;匹配的规则在struts…

Java进阶--static完全解析

前言 现在深深的感觉到写一篇好的博客十分不容易&#xff0c;static关键字的考点在各种笔试面试中会经常遇到&#xff0c;在写这篇博客之前我也是大量的阅读了相关的文献&#xff0c;争取从全局上对static做一个分析&#xff0c;这里的全局包括JVM&#xff08;Java虚拟机&…