Java线程和并发 - Synchronization

当线程不交互(比如共享变量)的时候,开发多线程程序是很容易的。当有交互的时候,会因为各种原因导致线程不安全。

问题

Java的线程支持有助于开发响应式和可伸缩的程序,但是很容易出现bug。

Race Conditions

竞争条件发生在计算的正确性依赖多线程的执行顺序的时候。看下面的代码:

    if (a == 10.0)
        b = a / 2.0;

在单线程环境下,这段代码不会有问题,在多线程环境下,如果a和b是局部变量也不会有问题。但是,假如a和b是类的成员变量而且有两个线程同时访问该代码的时候,就会出问题。
假设一个线程已经执行了if (a == 10.0),将要执行b = a / 2.0的时候被调度器暂停了,这时候另一个线程改变了a。那么第一个线程恢复执行的以后,b就不会等于5.0了。(如果a和b是局部变量,该竞争条件不会发生,这是因为每个线程有自己的局部变量拷贝)
这个代码片段是一种常见的竞争条件的例子,叫做check-then-act,它使用了旧的观察来决定下一步做什么。上面的代码,if (a == 10.0)是检查,b = a / 2.0是执行。
竞争条件的另一种类型是read-modify-write,新状态来自以前的状态。先是读,然后修改,最后更新,这样三个原子操作组成了整个操作,但是,他们的组合不是原子的。一个常见的例子是调用一个变量,加1,然后生成唯一的数字ID。比如下面的代码,假如counter是一个int类型的实例变量(初始值是1),然后两个线程同时访问了这段代码:

    public int getID() {
        return counter++;
    }

上面的代码,看上去是个简单的操作,表达式counter++实际上包含三个操作:读counter的值,加1,保存更新的值。假如线程1调用getID(),读counter的值,得到1,然后它被调度器暂停了。同时,线程2开始运行,调用getID(),读counter的值1,加1,保存到counter,然后返回线程2的值是1。然后,线程1恢复执行,在先前读的值1上加了1,counter保存成2,然后返回线程1的也是1。我们生成了不唯一的ID。

Data Races

竞争条件经常和数据竞争相混淆。数据竞争指一个程序内的两个或者更多线程并发访问相同的内存,并且至少一个访问是写,而且这些线程没有协调对该内存的访问。当这些条件成立时,访问顺序是不确定的。不同的顺序,会导致不同的结果:

    private static Parser parser;

    public static Parser getInstance() {
        if (parser == null)
            parser = new Parser();
        return parser;
    }

假如线程1先调用getInstance(),因为它得到一个null,它会实例化Parser,parser引用这个实例。当线程2随后调用getInstance()的时候,它观察到parser包含一个非空的引用,就返回了它的值。如果线程2也观察到null,就会再增加一个新的Parser对象-线程1的写和线程的读直接没有happens-before ordering(一个动作必须在另一个动作之前)。

Cached Variables

为了提高性能,编译器、JVM和操作系统可能在寄存器或者处理器的本地缓存缓存变量。每个线程都有自己的变量拷贝。当一个线程写变量的时候,它写到它的拷贝,其他线程不太可能在它的副本中看到更新。
比如上一篇文章中的计算PI的代码:

    private static BigDecimal result;

    public static void main(String[] args) {
        Runnable r = () -> result = computePi(50000);
        //工作线程
        Thread t = new Thread(r);
        t.start();
        try {
            //main线程等待工作线程的结束
            t.join();
        } catch (InterruptedException ie) {
            // 不会到达这里,因为不调用interrupt()
        }
        //工作线程结束以后,main线程打印输出,程序结束
        System.out.println(result);
    }

类变量result就存在缓存变量的问题。它由工作线程访问,执行result = computePi(50000);在一个lambda环境中,由默认的main线程执行System.out.println(result)。工作线程可以在它的result的拷贝中保存computePi()的返回值,而main线程打印自己的拷贝中的值。main可能看不到result = computePi(50000),它的拷贝还保留null值,这样就无法打印pi的值。

同步

可以使用同步解决上面提及的问题。Synchronization是JVM的特性,它确保两个或者更多的并发线程不同时执行一个临界区(critical section,必须串行访问的代码)。
同步的一个特性是mutual exclusion,当一个线程在临界区内时,其他线程都不能进入。因此,线程获取的锁叫mutex lock。还有一个特性是visibility,确保在临界区内执行的线程可以看到共享变量的最新修改。进入临界区时它从内存读这些变量,退出时把变量写到内存。
同步是根据monitors实现的,monitors是控制临界区访问的并发结构,必须原子地执行。每个Java对象都关联一个monitor,通过获取和释放该monitor的锁,线程可以lock或者unlock。(如果一个获取了锁的线程调用了sleep方法,不会释放锁)
只有一个线程能保持一个monitor锁。任何其他线程试图锁该monitor,会被阻塞,直到它能获得该锁。当一个线程退出临界区的时候,它通过释放锁,解锁了该monitor。
锁是可重入的,这样可以防止死锁。当一个线程试图获取已经保持的锁,请求会成功。
Java提供了关键字synchronized,来串行化对一个方法或者一个代码段(临界区)的访问。

使用Synchronized方法

比如在getID()方法上增加synchronized,就能克服read-modify-write竞争:

    public synchronized int getID() {
        return counter++;
    }

当在一个实例方法上同步的时候,锁关联到被调用方法所在的对象。比如,考虑下面的ID类:

public class ID {
    private int counter; // 初始值是0
    
    public synchronized int getID() {
        return counter++;
    }
}

假如你有下面的代码:

    ID id = new ID();
    System.out.println(id.getID());

锁关联的ID对象就是id引用的对象。如果另一个线程正在执行id.getID(),其他线程只能等待执行线程是否锁。
当在一个类方法上同步的时候,锁被关联到方法被调用的类的类对象,比如:

public class ID {
    private static int counter; // 初始值是0
    
    public static synchronized int getID() {
        return counter++;
    }
}

假如你有下面的代码:

    System.out.println(ID.getID());

锁被关联到ID.class,该类对象和ID关联。如果ID.getID()执行的时候,另一个线程也调用了它,就只能等待执行线程释放锁。

使用Synchronized块

语法是这样的,其中,锁关联object,object是一个任意的对象引用:

    synchronized(object) {
    /* statements */
    }

前面的缓存对象问题,可以使用同步块解决:

    Runnable r = () -> {
        synchronized(FOUR) {
            result = computePi(50000);
        }
    };
    //...
    synchronized(FOUR) {
        System.out.println(result);
    }

这两个代码块是一对临界区,每个块由相同的对象守护着,所以某一时刻,只有一个线程可以执行其中一个块。线程必须获取关联FOUR的锁,然后进入临界区。
两个或者更多线程想顺序访问相同代码必须获取相同的锁,或者他们不是同步的。相同的对象必须是可访问的。

防止liveness问题

术语liveness是指最终会发生有益的事情。liveness failure发生在程序到了无法继续的状态。比如一个单线程程序的无限循环。多线程程序还要面对deadlock、livelock和starvation的挑战:

  • Deadlock:线程1等待线程2拥有的资源,而线程2在等待线程1拥有的资源。这样,线程1和2都无法继续了
  • Livelock:线程尝试的操作总会失败
  • Starvation:即无限延期。线程要访问的资源不断被调度器拒绝

我们看一个死锁的例子:

public class DeadlockDemo {
    private final Object lock1 = new Object();
    private final Object lock2 = new Object();

    private void instanceMethod1() {
        synchronized (lock1) {
            synchronized (lock2) {
                System.out.println("first thread in instanceMethod1");
                //先是lock1,然后是lock2保护的临界区
            }
        }
    }

    private void instanceMethod2() {
        synchronized (lock2) {
            synchronized (lock1) {
                System.out.println("second thread in instanceMethod2");
                //先是lock2,然后是lock1保护的临界区
            }
        }
    }

    public static void main(String[] args) {
        final DeadlockDemo dld = new DeadlockDemo();
        Runnable r1 = () -> {
            while (true) {
                dld.instanceMethod1();
                try {
                    Thread.sleep(50);
                } catch (InterruptedException ie) {
                }
            }
        };
        Thread thdA = new Thread(r1);
        
        Runnable r2 = () -> {
            while (true) {
                dld.instanceMethod2();
                try {
                    Thread.sleep(50);
                } catch (InterruptedException ie) {
                }
            }
        };
        Thread thdB = new Thread(r2);
        thdA.start();
        thdB.start();
    }
}

可以看到,线程A想获取lock2关联的锁。因为线程B持有该锁,JVM强制线程A等待,不能进入内部临界区。而线程B想获取lock1关联的锁。因为线程A持有该锁,JVM强制线程B等待,不能进入内部临界区。于是,两个线程都不能继续。

volatile和final

同步有两个属性:互斥和可视。关键字synchronized和这两个属性相关。Java也提供了弱形式的同步-只和可视性相关,这就是volatile关键字。
假如你想停止一个线程,看下面的代码:

public class ThreadStopping {
    public static void main(String[] args) {
        class StoppableThread extends Thread {
            private boolean stopped; // defaults to false

            @Override
            public void run() {
                while (!stopped)
                    System.out.println("running");
            }

            void stopThread() {
                stopped = true;
            }
        }
        StoppableThread thd = new StoppableThread();
        thd.start();
        try {
            Thread.sleep(1000); // sleep for 1 second
        } catch (InterruptedException ie) {
        }
        thd.stopThread();
    }
}

上面的代码,main线程休眠1秒以后,停止了StoppableThread的实例,程序退出。
当你在单处理器或者单核机器上运行该程序,可能会观察到程序停下来。如果是多处理器环境,可能就停不下来。当一个线程修改了这个成员的拷贝的时候,其他线程的拷贝没有修改。
应该这样修改:

    private volatile boolean stopped; // defaults to false

因为stopped标记成volatile,每个线程会访问内存的拷贝而不是缓存的拷贝。
当一个成员变量被声明为volatile,就不能声明为final。这不是问题,Java会让你安全访问一个final成员,而不需要同步。
我们经常使用final,确保一个不可变类的线程安全问题。

public class Planets {
    private final Set<String> planets = new TreeSet<>();

    public Planets() {
        planets.add("Mercury");
        planets.add("Venus");
        planets.add("Earth");
        planets.add("Mars");
        planets.add("Jupiter");
        planets.add("Saturn");
        planets.add("Uranus");
        planets.add("Neptune");
    }

    public boolean isPlanet(String planetName) {
        return planets.contains(planetName);
    }
}

上面的代码实现了一个不可变的Planets类,它的对象保存在Set内。因为该Set是final的,所以防止在构造器退出以后修改它。
Java为不可变对象提供了特殊的线程安全保证。这些对象能被多线程安全访问:

  • 不可变对象必须不允许修改
  • 所有成员都被声明为final
  • 必须正确构造对象,“this”不会从构造器中逃脱

我们举例看看最后一点:

public class ThisEscapeDemo {
    private static ThisEscapeDemo lastCreatedInstance;

    public ThisEscapeDemo() {
        lastCreatedInstance = this;
    }
}

可以访问Safe construction techniques以了解细节。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: Java中的线程同步和异步指的是线程间如何协作。 同步(Synchronization)是指线程之间的协调,确保每一个时刻只有一个线程执行某段代码。 异步(Asynchronization)是指线程之间不需要协调,多个线程可以同时执行某段代码。 简单来说,同步是线程之间互相等待,异步是线程之间互不干扰。 ### 回答2: Java线程同步和异步是两种不同的多线程编程概念。 线程同步是指多个线程之间按照一定的顺序来共享数据和执行任务,以保证数据的一致性和正确性。在同步过程中,一个线程执行到某一点时,其他想要访问该点的线程必须等待,直到执行完毕才能继续执行。同步机制通过synchronized关键字、ReentrantLock等机制来实现,可以避免多个线程同时修改共享数据而导致的数据不一致问题。 而线程异步则是指多个线程之间独立运行,并不按照特定顺序来共享数据和执行任务。每个线程独立执行自己的任务,彼此之间无需等待。线程异步可以提高程序的并发性和性能,但同时也会增加编程复杂度和出错可能性。在异步处理中,通常使用线程池或者Future接口等机制来实现异步执行。 综上所述,线程同步和异步的区别在于线程之间的执行顺序和数据访问方式。同步机制可以保证数据的一致性和正确性,但可能会造成性能问题;而异步机制可以提高并发性和性能,但可能会引发编程复杂度和出错可能性。根据具体的应用场景和需求,选择合适的线程同步或异步方式来实现多线程编程。 ### 回答3: Java线程同步和异步是两种不同的处理机制。 线程同步是指多个线程按照一定的顺序执行,其中一个线程完成了特定的任务后,其他线程才能继续执行。在Java中,可以通过关键字synchronized和Lock来实现线程同步。使用同步机制可以有效地避免多个线程对共享资源的竞争,保证数据的一致性和正确性。然而,线程同步也有一些缺点,比如可能会引起死锁和性能下降。 线程异步是指多个线程可以独立执行,彼此之间不需要等待。每个线程可以以不同的顺序和速度执行任务,因此具有更高的并发性。在Java中,可以使用线程池和CompletableFuture等机制来实现线程的异步执行。异步编程可以提高程序的响应速度和吞吐量,并提高系统的并发性能。然而,异步编程在处理共享资源时需要额外的注意,因为多个线程可能会并发地访问和修改共享资源,可能引发数据的不一致和竞争条件。 综上所述,线程同步和异步是不同的线程处理机制。线程同步保证了多个线程按照一定的顺序执行,避免了竞争条件和数据的不一致;而线程异步则可以独立执行,提高了并发性能,但需要额外注意共享资源的并发访问和修改。在实际开发中,需要根据具体的需求和场景来选择合适的处理机制。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值