Java并发/多线程系列——线程安全篇

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java并发/多线程系列——线程安全篇相关的知识,希望对你有一定的参考价值。

创建和启动Java线程

Java线程是个对象,和其他任何的Java对象一样。线程是类的实例java.lang.Thread,或该类的子类的实例。除了对象之外,java线程还可以执行代码。

创建和启动线程

在Java中创建一个线程是这样完成的:

 Thread thread = new Thread();

要启动Java线程,您将调用其start()方法,如下所示:

thread.start();

此示例不指定要执行的线程的任何代码。启动后,线程将立即停止。

有两种方法来指定线程应该执行什么代码。第一个是继承Thread类并覆盖run()方法。第二种方法是实现Runnable (java.lang.Runnable对 Thread构造函数)的接口,这两个方法都在下面。

继承Thread

创建线程的第一种方法是创建Thread的子类并覆盖该run()方法。当执行start()方法后,会另起一个线程调用该run()方法。以下是创建Java Thread子类的示例:

public class MyThread extends Thread {

  public void run(){
     System.out.println("MyThread running");
  }
}

 

启动线程:

MyThread myThread = new MyThread();
myTread.start();

start()一旦线程启动, 该调用将返回。它不会等到run()方法完成。该run()方法将像执行不同的CPU一样执行。当run()方法执行时,它将打印出文本“MyThread running”。

你也可以创建一个这样的匿名子类的Thread

复制代码
Thread thread = new Thread(){
  public void run(){
    System.out.println("Thread Running");
  }
}

thread.start();
复制代码

 

此示例将打印出文本“Thread running” 。

实现Runnable接口

创建线程的第二种方法是创建一个java.lang.Runnable接口的实现类。该实现类可以通过一个被执行Thread运行。

示例:

public class MyRunnable implements Runnable {

  public void run(){
     System.out.println("MyRunnable running");
  }
}

 

要执行run()方法,需要创建拥有MyRunnable实例的Thread对象,如下:

Thread thread = new Thread(new MyRunnable());
thread.start();

 

当线程启动时,它将调用MyRunnablerun()方法。上面的例子将打印出文本“MyRunnable running”。

您还可以创建一个匿名实现Runnable,像这样:

复制代码
Runnable myRunnable = new Runnable(){

   public void run(){
      System.out.println("Runnable running");
   }
 }

Thread thread = new Thread(myRunnable);
thread.start();
复制代码

 

继承Thread父类还是实现Runnable接口?

这两种方法没有说哪一种是最好的,这两种方法都有效。我个人而言,我更喜欢使用Runnable,并将实现的一个实例移交给一个Thread实例。当Runnable通过线程池执行该操作时,Runnable 实例很容易列入队列中,直到来自池的线程空闲时再运行run()方法。而Thread的子类就难于实现。

有时你可能需要实现Runnable和子类Thread。例如,创建一个子类Thread可以执行多个Runnable。实现线程池时通常是这种情况。

常见的陷阱:调用run()而不是start()

当创建和启动一个线程时,一个常见的错误是调用run()方法而不是Threadstart(),像这样:

Thread newThread = new Thread(MyRunnable());
newThread.run(); //应该是start();

起初你可能不会注意到这样会发生错误,因为它Runnablerun()方法是像你预期的那样执行。但是,它不是刚刚创建的新线程执行。相反,该run()方法由创建线程的线程执行。换句话说,执行上述两行代码的线程。要由新创建的线程去调用MyRunnable实例的run()方法,你必须通过newThread.start()去调用。

线程名称

创建Java线程时,可以给它一个名称。该名称可以帮助您区分不同的线程。例如,如果多个线程写入System.out,它可以方便地查看哪个线程写了文本。两种不同的创建线程方式的例子:

复制代码
Thread thread = new Thread("New Thread"){
    public void run(){
      System.out.println("run by:" + getName());
   }
};


thread.start();
System.out.println(thread.getName());
复制代码

 

MyRunnable runnable = new MyRunnable();
Thread thread = new Thread(runnable, "New Thread");

thread.start();
System.out.println(thread.getName());

 

但是请注意,由于MyRunnable类不是 Thread的子类,所以它无法通过执行getName()去获取线程名字。

获取当前线程

Thread.currentThread()方法能够返回当前线程的实例,这样你就可以获取到当前线程中你想得到的东西。例如,您可以获取当前执行代码的线程的名称,如下所示:

Thread thread = Thread.currentThread();
String threadName = Thread.currentThread().getName();

Java Thread示例

这是一个小例子。首先打印执行该main()方法的线程的名称。该线程由JVM分配。然后它启动10个线程,并给它们全部一个数字作为name("" + i)。然后每个线程将其名称输出,然后停止执行。

复制代码
public class ThreadExample {
    
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName());
        for(int i = 0; i <10; i ++){
          new Thread("" + i){
            public void run(){
              System.out.println("Thread:" + getName() +"running");
            }
          }.start();
        }
    }

}
复制代码

请注意,即使线程按顺序(1,2,3...)启动,它们可能不会按顺序执行,这意味着线程0可能不是第一个用System.out把线程名称输出的。这是因为线程原则上是并行执行而不是顺序执行的。JVM操作系统决定执行线程的顺序。每次运行结果会不相同,因此这个顺序不一定是他们的执行顺序。

竞争条件(Race Conditions)和临界区(Critical Sections)

竞争条件是在临界区内可能出现的一种特殊情况。临界区是一种轻量级机制,在某一时间内只允许一个线程执行某个给定代码段。

当多线程在临界区执行时,执行结果可能会根据线程执行的顺序而有所不同,临界区被称为包含竞争条件。竞争条件一词来自比喻,即线程正在通过临界区时进行赛跑,而竞争的结果影响了执行临界区的结果。

这可能听起来有点复杂,所以我将在以下部分详细阐述竞争条件和临界区。

临界区

在同一应用程序中运行多个线程本身不会导致问题。当多个线程访问相同的资源时,就会出现问题。例如多个线程同时访问相同的内存(变量,数组或对象),系统(数据库,Web服务等)或文件。

事实上,只有一个或多个线程写入这些资源时才会出现问题。只要资源不变,可以安全地让多个线程读取相同的资源。

以下是一个临界区的代码示例,如果多个线程同时执行,则可能会失败:

复制代码
public class Counter {

   protected long count = 0;

   public void add(long value){
       this.count = this.count + value;
   }
}
复制代码

想象一下,如果两个线程A和B正在同一个Counter类的实例上执行add方法。没有办法知道操作系统何时在两个线程之间切换。该add()方法中的代码不会作为Java虚拟机的单个原子指令执行。相反,它作为一组较小的指令执行,类似于此:

  1. 把这个记录从内存读入注册表。
  2. 添加值进行注册。
  3. 写入寄存器到内存

观察以下的线程A和B的混合执行会发生什么:

复制代码
 this.count = 0;

A:把这个记录读入一个寄存器(0)
B:将此记录读入注册表(0)
B:添加值2进行注册
B:将寄存器值(2)写入内存。this.count现在等于2
A:添加值3进行注册
A:将寄存器值(3)写入内存。this.count现在等于3
复制代码

 

两个线程想要将值2和3添加到计数器。因此,两个线程完成执行后的值应该是5。然而,由于两个线程同时执行,所以结果会有所不同。

在上面列出的执行顺序示例中,两个线程从内存中读取值0。然后,他们将它们的个人值2和3添加到值中,并将结果写回内存。而不是5,剩下的值 this.count将是最后一个线程写入其值的值。在上面的情况下,它是线程A,但也可能是线程B.

临界区的竞争条件

上例中的add()方法就包含临界区,当多个线程执行此临界区时,会发生竞争条件。

多个线程竞争相同资源时,其中访问资源的顺序是重要的,称为竞争条件。导致竞争条件的代码部分称为临界区。

防止竞争条件

为了防止发生竞争条件,您必须确保临界区作为原子命令执行。这意味着一旦一个线程正在执行它,就不能有其他线程可以执行它,直到第一个线程离开临界区。

临界区的竞争条件可以通过适当的线程同步来避免。可以使用Java代码的同步块来实现线程同步。线程同步也可以使用其他同步结构(如锁或原子变量,如java.util.concurrent.atomic.AtomicInteger)来实现。

复制代码
public class TwoSums {
    
    private int sum1 = 0;
    private int sum2 = 0;
    
    public void add(int val1, int val2){
        synchronized(this){
            this.sum1 += val1;   
            this.sum2 += val2;
        }
    }
}
复制代码

然而,由于两个和变量是相互独立的,所以您可以将它们的求和分解为两个单独的同步块,如下所示:

复制代码
public class TwoSums {
    
    private int sum1 = 0;
    private int sum2 = 0;

    private Integer sum1Lock = new Integer(1);
    private Integer sum2Lock = new Integer(2);

    public void add(int val1, int val2){
        synchronized(this.sum1Lock){
            this.sum1 += val1;   
        }
        synchronized(this.sum2Lock){
            this.sum2 += val2;
        }
    }
}
复制代码

现在两个线程可以同时执行该add()方法。两个同步块在不同的对象上同步,因此两个不同的线程可以独立执行两个块。这样线程将就有较少的等待去执行add()方法。

这个例子当然很简单。在现实生活中的共享资源中,临界区的分解可能会更复杂一些,并且需要更多的分析执行顺序的可能性。

线程安全和共享资源

多线程同时安全地调用被称为线程安全。如果一段代码是线程安全的,那么它不包含任何竞争条件。竞争条件仅在多个线程更新共享资源时发生。因此,重要的是要知道什么共享资源会被多线程同时执行。

局部变量

局部变量存储在每个线程自己的堆栈中。这意味着局部变量从不在线程之间共享。这也意味着所有本地变量基本上都是线程安全的。以下是本地变量的线程安全的示例:

public void someMethod(){

  long threadSafeInt = 0;

  threadSafeInt++;
}

本地对象的引用

引用本身不是共享的。但是,引用的对象不存储在每个线程的本地堆栈中,所有对象都存储在共享堆中。

如果本地创建的对象永远不会通过创建他的方法返回,那么它是线程安全的。实际上,只要没有让对象在方法之间传递后用于其他线程。

这是一个线程安全的本地对象的示例:

复制代码
public void someMethod(){

  LocalObject localObject = new LocalObject();

  localObject.callMethod();
  method2(localObject);
}

public void method2(LocalObject localObject){
  localObject.setValue("value");
}
复制代码

 

上面这个例子,someMethod()这个方法没有将LocalObject传递出去,而是每个线程调用someMethod()都会创建一个新的LocalObject,并在自己的方法内部消化,所以这里是线程安全的。

对象成员变量

对象成员变量与对象一起存储在堆上。因此,如果两个线程调用同一对象实例上的方法,并且此方法更新该对象的成员变量,则该方法是线程不安全的。这是一个线程不安全的例子:

复制代码
public class NotThreadSafe{
    StringBuilder builder = new StringBuilder();

    public add(String text){
        this.builder.append(text);
    }
}
复制代码

如果两个线程在同一个NotThreadSafe实例上同时调用add()方法那么它会导致竞争条件。例如:

复制代码
NotThreadSafe sharedInstance = new NotThreadSafe();

new Thread(new MyRunnable(sharedInstance)).start();
new Thread(new MyRunnable(sharedInstance)).start();

public class MyRunnable implements Runnable{
  NotThreadSafe instance = null;

  public MyRunnable(NotThreadSafe instance){
    this.instance = instance;
  }

  public void run(){
    this.instance.add("some text");
  }
}
复制代码

但是,如果两个线程在不同的实例上同时调用add()方法 那么它们不会产生竞争条件。把上面的例子稍加修改:

new Thread(new MyRunnable(new NotThreadSafe())).start();
new Thread(new MyRunnable(new NotThreadSafe())).start();

 

现在这两个线程都拥有自己的实例对象,所以他们调用add方法时不会互相干扰。代码没有竞争条件了。所以即使一个对象是线程不安全的,它仍然可以以不会导致竞争条件的方式运行。

线程控制逃离准则(The Thread Control Escape Rule)

为了确定你的代码对某个资源的访问是否是线程安全的,您可以使用“线程控制逃离准则”:

如果一个资源的创建、使用和回收都在同一个线程内完成的,并且从来没有逃离这个线程的控制域,那么该资源就是线程安全的

If a resource is created, used and disposed within the control of the same thread, and never escapes the control of this thread, the use of that resource is thread safe.

资源可以是任何形式的共享资源,如对象,数组,文件,数据库连接,套接字等。在Java中,你并不总是明确地回收某个对象,因此“回收”意味着对该对象的引用不再使用或者置为 null。

即使使用线程安全的对象,如果该对象指向一个共享资源,如文件或数据库,那么整个应用程序可能不是线程安全的。例如,如果线程1和线程2都创建自己的数据库连接,连接1和连接2,则使用每个连接本身是线程安全的。但是使用数据库的连接点可能不是线程安全的。例如,如果两个线程执行这样的代码:

check if record X exists
if not, insert record X

如果两个线程同时执行,并且他们正在检查的记录X恰好是相同的记录,那么就存在两个线程都进行插入的动作。那么这就是线程不安全的。

这种情况也可能发生在对文件或者其他共享资源的操作上。因此,一定要区分一个线程所控制的对象到底是资源本身还是指向资源的一个引用

线程安全和不变性

竞争条件只有在多个线程同时访问同一资源多个线程同时写入资源时才会发生。如果多线程读取相同的资源,那么竞争条件不会发生。

我们可以通过让共享对象不可变来确保多线程永远不会更新该对象,从而保证线程安全。例如:

复制代码
public class ImmutableValue{

  private int value = 0;

  public ImmutableValue(int value){
    this.value = value;
  }

  public int getValue(){
    return this.value;
  }
}
复制代码

注意ImmutableValue实例的属性value在构造函数中赋值。还要注意该没有提供setter方法。一旦ImmutableValue实例被创建,你不能改变它的属性value。当然,您可以使用该getValue()方法读它。

如果需要对ImmutableValue实例执行操作,可以通过操作得到返回一个新的实例来改变value的值,从而不改变原实例的value值。看下面例子会更加清晰:

复制代码
public class ImmutableValue{

  private int value = 0;

  public ImmutableValue(int value){
    this.value = value;
  }

  public int getValue(){
    return this.value;
  }

  
      public ImmutableValue add(int valueToAdd){
      return new ImmutableValue(this.value + valueToAdd);
      }
  
}
复制代码

 

请注意该add()方法返回的是一个新实例,而不是改变自身实例的value值。

实例的引用是线程不安全

非常重要的是,即使一个对象是不可变的,因此是线程安全,但该对象的引用可能不是线程安全的。例如:

复制代码
public class Calculator{
  private ImmutableValue currentValue = null;

  public ImmutableValue getValue(){
    return currentValue;
  }

  public void setValue(ImmutableValue newValue){
    this.currentValue = newValue;
  }

  public void add(int newValue){
    this.currentValue = this.currentValue.add(newValue);
  }
}
复制代码

Calculator类持有一个ImmutableValue实例的引用。但是Calculator可以通过方法setValue() 和add()方法来改变引用。因此,即使Calculator类在内部使用不可变对象ImmutableValue,但它本身不具有不变性,因此是线程不安全的。换句话说:ImmutableValue该类是线程安全的,但使用它的不是。当尝试通过不变性实现线程安全性时,需要牢记这一点。

为了使Calculator类线程安全,你可以将getValue(), setValue()add()方法加synchronized

 

from: https://www.cnblogs.com/bug-zhang/p/7624254.html

以上是关于Java并发/多线程系列——线程安全篇的主要内容,如果未能解决你的问题,请参考以下文章

Linux线程安全篇

Linux线程安全篇Ⅰ

Linux线程安全篇Ⅱ

Java多线程系列:线程的五大状态,以及线程之间的通信与协作

Java多线程系列:线程的五大状态,以及线程之间的通信与协作

java多线程系列