java多线程基础篇之线程安全

来源:互联网 发布:微云同步盘 mac 编辑:程序博客网 时间:2024/05/17 07:49

引言:由于java内存模型和多线程机制可知,当多个线程操作同一个变量时候会引发安全性问题,大多是:可见性问题。

java内存模型:


从图中可知:每个线程都会有一个虚拟的工作内存,这个内存储线程内变量的地方,主内存(即堆内存:存储类实例域、静态实例域、数组元素等)

过程:要想实现可见性,也就是线程间可以访问同一个变量,那么线程A改变变量的值,首先是在自己的工作内存中有一份数值,之后是把工作内存中的数值写入到主内存中。

这时候如果线程B获得执行权,那么当有同步的时候线程B会先去主内存中加载一份数据到自己的工作内存中去,如果有同步这个过程不会被打断,否则就会出现不可见性。

为此JAVA虚拟机还规定了一系列操作规则:

(1) read和load、store和write必须要成对出现,不允许单一的操作,否则会造成从主内存读取的值,工作内存不接受或者工作内存发起的写入操作而主内存无法接受的现象。

(2) 在线程中使用了assign操作改变了变量副本,那么就必须把这个副本通过store-write同步回主内存中。如果线程中没有发生assign操作,那么也不允许使用store-write同步到主内存。

(3) 在对一个变量实行use和store操作之前,必须实行过load和assign操作。

(4) 变量在同一时刻只允许一个线程对其进行lock,有多少次lock操作,就必须有多少次unlock操作。

(5) 在lock操作之后会清空此变量在工作内存中原先的副本,需要再次从主内存read-load新的值。

(6) 在执行unlock操作前,需要把改变的副本同步回主存。

java内存的三个特性:

1.原子性:保证他们会被当作不可分的操作来操作内存而不发生上下文切换(切换到其他线程)。原子操作可由线程机制保证其不可中断。要保证更大范围的原子性,可以在代码里使用synchronized关键字(也叫同步锁,对应字节码指令monitorenter/monitorexit)。一组语句作为一个不可分割的单元被执行。任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块。

2.可见性:指一个线程修改了一个共享变量的值,其他线程能够立即得知这个修改。synchronized关键字保证了可视性。上面的规则(6)保证了这一点。另外volatile关键字也有相同作用。可见性要保证某个线程以一种可预测的方式来查看另一个线程的执行结果。(即当线程B在执行由锁保护的同步代码块时,可以看到线程A之前在同一个同步代码块中的所有操作结果(Happens-Before关系))。加锁保证可见性如下图


3.有序性:JMM(Java内存模型,Java Memory Model)不保证线程对变量操作发生的顺序和被其他线程看到的是同样的顺序。 JMM容许线程以写入变量时所不相同的次序把变量存入主存。编译器和处理器可能会为了性能优化,进行重新排序,Volatile关键字的另一个作用就是可以防止编译器的优化,防止重排序。后面讲到volatile,在详细谈重排序现象。JAVA线程机制和JMM的特点决定了多线程运行过程中的不确定性,因此要想保证线程的安全就必须进行线程同步。

同步:

package test0531;public class Test implements Runnable {      private  volatile int i = 0;        private int getNext() {          return i++;      }        @Override      public void run() {          while (i< 10) {         synchronized (this) {//同步代码块        System.out.println(Thread.currentThread().getName()+":"+getNext());}        }      }            public static void main(String[] args) {              Test t = new Test();              Thread t1 = new Thread(t);              Thread t2 = new Thread(t);              t1.start();              t2.start();              Thread.yield();      }  } 

运行结果:

这里在加了同步代码块之后发现运行结果正确

分析:由于cpu在快速切换,所以每个线程都有可能获得执行权,所以会发生某个线程在访问变量时候,cpu却将执行权给了另一个线程,会发生一个线程从主存中获取摸个变量的值还没来得及修改或者说已经修改了但是还没来记得同步到主存中去,另一个线程就获得了cpu执行权,去主存中读取变量值,当cpu再次切换到之前的线程时候,还会从之前的终端处开始执行或者修改变量,执行权回到第二个线程时却不能看到第一个线程中改变了的值。归结起来就是说违背了线程内存的可见性,如下图:

i++对应下面的JVM指令,因此在期间另一个线程都可能会修改这个变量。

4: aload_0

5: iconst_0

6: putfield      #2                  // Field i:I

为了体现内存的可见性,synchronized关键字能使它保护的代码以串行的方式来访问(同一时刻只能由一个线程访问)。保证某个线程以一种可预测的方式来查看另一个线程的执行结果。

线程同步

JAVA提供的锁机制包括同步代码块和同步方法。

每个Java对象都可以用做一个实现同步的锁,这些锁称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock),一个线程进入同步代码块之前会自动获得锁,并且退出同步代码块时自动释放锁。获得内置锁的位移途径就是进入由这个锁保护的同步代码块或方法并且该锁还未被其他线程获得。

Java内置锁相当于互斥体(互斥锁),意味着最多有一个线程持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁,如果线程B永远不释放锁,那么线程A将永远等待下去。每次只能有一个线程执行内置锁保护的代码块,因此这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。原子性的含义:一组语句作为一个不可分割的单元被执行。任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块。

千万注意:并不是说synchronized代码块或者synchronized方法是不可分割的整体,是原子的,因为,显然使用不同锁的话之间不存在互斥关系。

经典买票例子

下面是模拟火车站卖票的程序,理论上是要将编号为1-10的票卖按照由大到小顺序卖出去,结果用两个窗口(线程)卖就出现了这样的结果,有些编号的票卖了两次,有些没卖出去,并且还有编号为0的票卖了出去。显然结果错误的。

package test0531;public class Test1 implements Runnable {private int i = 10;private void sale() {while (true) {if (i > 0) {try {Thread.sleep(10);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(Thread.currentThread().getName() + "正在卖第" + i + "张票");i--;} elsebreak;}}@Overridepublic void run() {sale();}public static void main(String[] args) {Test1 t = new Test1();Thread t1 = new Thread(t);Thread t2 = new Thread(t);t1.setName("线程一");t2.setName("线程二");t1.start();t2.start();Thread.yield();}}
结果:

线程一正在卖第10张票
线程二正在卖第10张票
线程一正在卖第8张票
线程二正在卖第7张票
线程一正在卖第6张票
线程二正在卖第6张票
线程二正在卖第4张票
线程一正在卖第4张票
线程二正在卖第2张票
线程一正在卖第1张票
线程二正在卖第0张票

出现这种结果的原因就是没有对多个线程共同访问的资源进行同步加锁。下面我们对其进行线程同步,达到想要的效果:

package test0531;public class Test2 implements Runnable {      private int i = 10;      private void sale(){          Object o = new Object();          while (true) {              synchronized(o){                  if (i >0){                      System.out.println(Thread.currentThread().getName() + "正在卖第" + i + "张票");                      i--;                  }else  break;                        }          }      }        @Override      public void run() {          sale();      }            public static void main(String[] args) {              Test2 t = new Test2();              Thread t1 = new Thread(t);              Thread t2 = new Thread(t);            t1.setName("线程一");    t2.setName("线程二");            t1.start();              t2.start();              Thread.yield();      }  }   

结果:
线程一正在卖第10张票
线程二正在卖第10张票
线程二正在卖第8张票
线程二正在卖第7张票
线程二正在卖第6张票
线程二正在卖第5张票
线程一正在卖第9张票
线程一正在卖第3张票
线程一正在卖第2张票
线程一正在卖第1张票
线程二正在卖第4张票

发现结果还是有问题,在试一下:

package test0531;public class Test3 implements Runnable {private int i = 10;Object o = new Object();// 通常使用:/*static*/ byte[] lock = new byte[0];//这里的对象是全局的,存在于队内存中,可以被方法共享,随着类的加载和消失而动,所以当不同线程//访问时候在持有和释放的锁都是同一个。private void sale() {while (true) {try {Thread.sleep(10);} catch (InterruptedException e) {e.printStackTrace();}synchronized (o) {if (i > 0) {System.out.println(Thread.currentThread().getName() + "正在卖第" + i + "张票");i--;} elsebreak;}}}@Overridepublic void run() {sale();}public static void main(String[] args) {Test3 t = new Test3();Thread t1 = new Thread(t);Thread t2 = new Thread(t);t1.setName("线程一");t2.setName("线程二");t1.start();t2.start();Thread.yield();}}

结果:

线程一正在卖第10张票
线程二正在卖第9张票
线程二正在卖第8张票
线程一正在卖第7张票
线程二正在卖第6张票
线程一正在卖第5张票
线程二正在卖第4张票
线程一正在卖第3张票
线程二正在卖第2张票
线程一正在卖第1张票

结果正确:

一个线程执行到synchronized代码块,线程尝试给同步锁上锁,如果同步锁已经被锁,则线程不能获取到锁,线程就被阻塞;如果同步锁没被锁,则线程将同步锁上锁,并且持有该锁,然后执行代码块;代码块正常执行结束或者非正常结束,同步锁都将解锁。

所以线程执行同步代码块时,持有该同步锁。其他线程不能获取锁,就不能进入同步代码块(前提是使用同一把锁),只能等待锁被释放。

这时候回头看上上段代码中的同步代码块,由于两个线程使用的锁是不一样的(创建了两个对象),因此,就算线程A在执行同步代码块,当线程2获得CPU执行权时,检查到这个锁并未被其他线程锁定,因此不具有互斥性,不能达到线程同步的效果。

同步方法

将synchronized作为关键字修饰类的某个方法,这样该方法就变成了同步方法。

package test0531;

public class Test4 implements Runnable {  
    private int i = 10;  
    private void sale(){  
        while (true) {  
            try {  
                Thread.sleep(10);  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
            f();  
        }  
    }
    //持有this对象锁,要这个方法执行完,其他线程才可以获得执行权,因为一旦synchronized修饰了方法,这个方法就会具有原子性
    //在执行方法时候不会被中断。所以把while循环放在同步方法外面来控制数量,如果放在里面,那么一个线程会把所有的票都卖完在释放锁。

    private synchronized void f(){  
        if (i >0){  
            System.out.println(Thread.currentThread() + "正在卖第" + i + "张票");  
            i--;  
        }else  
            return;  
    }  
 
    @Override  
    public void run() {  
        sale();  
    }  
      
    public static void main(String[] args) {  
            Test4 t = new Test4();  
            Thread t1 = new Thread(t);  
            Thread t2 = new Thread(t);  
            t1.start();  
            t2.start();  
            Thread.yield();  
    }  
}  
结果:

Thread[Thread-1,5,main]正在卖第10张票
Thread[Thread-0,5,main]正在卖第9张票
Thread[Thread-0,5,main]正在卖第8张票
Thread[Thread-1,5,main]正在卖第7张票
Thread[Thread-0,5,main]正在卖第6张票
Thread[Thread-1,5,main]正在卖第5张票
Thread[Thread-0,5,main]正在卖第4张票
Thread[Thread-1,5,main]正在卖第3张票
Thread[Thread-0,5,main]正在卖第2张票
Thread[Thread-1,5,main]正在卖第1张票

注:在synchronized代码里面使用sleep无效。因为该线程sleep后CPU不在为其分配时间片,但是这个时候线程已经拿到了同步锁,即使睡到天荒地老,它也不会把同步锁交出去,别的线程得到了CPU执行却却苦于没有同步锁而被拒之门外。后面学习线程的状态会讲到这些。

volatile:保证可见性,但不能保证原子性。

volatile的非原子性

变量被定义为volatile并不能保证对其所有操作是原子的,由于非原子性,因此volatile并不能保证多线程并发的安全性。如下面的代码:

package test0531;public class Test5 implements Runnable{//volatile只能保证可见性,不能保证原子性,还会有线程安全问题,可以用于多线程执行控制共享的变量,但不要只靠这个关键字来保证同步    public volatile int race = 0;      @Override      public void run() {          increase();      }    //这一步才是具体的++逻辑,只有这一步是同步的,出现的结果才会一直正确,用于保证原子性    private void increase() {          race ++;                }      public static void main(String[] args) {          Test5 t = new Test5();          Thread [] threads = new Thread[1000];          for (int i = 0; i < 1000; i++) {              threads[i] = new Thread(t);              threads[i].start();          }          while (Thread.activeCount() > 1) {              Thread.yield();          }          // 保证打印的时候1000个线程都已经执行完毕          System.out.println(t.race);      }  }

这段代码开启了1000个线程,对race变量进行自增操作。理论上,线程安全的话,执行结果应该是1000。但实际上执行得到的结果都是一个小于1000的值。

分析一下上面案的代码,问题就出在了race++这句代码。它不是原子操作。这句代码实际上是分为三个操作的:读取race的值、进行加1操作、写入新的值。

显然可以看出来,将变量定义成vilatile也不能保证原子性:

线程1先读取了变量race的原始值,然后线程1被阻塞了;线程2也去读取变量race的原始值,然后进行加1操作,并把+1后的值写入工作内存,最后写入主存,然后线程1接着进行加1操作,由于已经读取了race的值,此时在线程1的工作内存中race的值仍然是之前的值,所以线程1对race进行加1操作后的值和刚才一样,然后将这个值写入工作内存,最后写入主存。这样就出现了两个线程自增完后其实只加了一次。究其原因是因为volatile不能保证原子性。

可以将自增操作改为同步代码块即可解决。

private synchronized void increase() {         race ++;      } 

volatile的可见性

jvm运行时刻内存的分配。其中有一个内存区域是jvm虚拟机栈,每一个线程运行时都有一个线程栈,

线程栈保存了线程运行时候变量值信息。当线程访问某一个对象时候值的时候,首先通过对象的引用找到对应在堆内存的变量的值,然后把堆内存

变量的具体值load到线程本地内存中,建立一个变量副本,之后线程就不再和对象在堆内存变量值有任何关系,而是直接修改副本变量的值,

在修改完之后的某一个时刻(线程退出之前),自动把线程变量副本的值回写到对象在堆中变量。这样在堆中的对象的值就产生变化了。下面一幅图

描述这写交互

read and load 从主存复制变量到当前工作内存
use and assign  执行代码,改变共享变量值
store and write 用工作内存数据刷新主存相关内容

其中use and assign 可以多次出现

但是这一些操作并不是原子性,也就是 在read load之后,如果主内存count变量发生修改之后,线程工作内存中的值由于已经加载,不会产生对应的变化,所以计算出来的结果会和预期不一样

对于volatile修饰的变量,jvm虚拟机只是保证从主内存加载到线程工作内存的值是最新的

例如假如线程1,线程2 在进行read,load 操作中,发现主内存中count的值都是5,那么都会加载这个最新的值

在线程1堆count进行修改之后,会write到主内存中,主内存中的count变量就会变为6

线程2由于已经进行read,load操作,在进行运算之后,也会更新主内存count的变量值为6

导致两个线程及时用volatile关键字修改之后,还是会存在并发的情况。








原创粉丝点击