线程2的深度剖析

加锁

synchronized

1.修饰方法(普通方法,静态方法)普通方法实际上加到了this上,静态方法加到了类对象上。

2.修饰代码块   手动指定加到那个对象上

明确锁对象针对那个对象加锁,如果两个线程针对同一个对象加锁,就会出现锁竞争,一个线程先能获取到锁,另一个线程阻塞等待,等待上一个线程解锁,它才能获取锁成功

如果两个线程针对不同对象加锁,就不会产生锁竞争,这两个线程都能获取到各自的锁

如果两个线程,一个线程加锁,另一个线程不加锁,这个时候不会有锁竞争

synchronized修饰方法 

class flg
{
    public static int m=0;
    public synchronized  void add1()
    {
        m++;
    }
}

synchronized修饰代码块

class flg
{
    public static int m=0;
    public  void add2()
    {
        synchronized (this)
        {
            m++;
        }
    }
}

 这两种写法本质上是一样的。

一个加锁另一个不加锁的情况

class flg
{
    public static int m=0;
    public  void add2()
    {
        synchronized (this)
        {
            m++;
        }
    }
    public void add()
    {
        m++;

    }
}

当两个线程分别去调用这两个方法是,实际上相当于没加锁

synchronized的力量是jvm提供的,jvm的力量是操作系统提供的,操作系统的力量是CPU提供的,从根本上说,是CPU提供了这样的指令才能让操作系统的API提供给JVM,JVM提供给synchroniz

d。

3.可重入

一个线程针对同一个对象连续连续加锁两次,如果没问题,就叫可重入,如果有问题就叫不可重入

例如这段代码

 public synchronized void add2()
    {
        synchronized (this)
        {
            a++;
        }
    }

 锁对象是this,只要有线程调用add,进入add方法的时候就会先加锁(能够加锁成功),紧接着又遇到了代码块,再次尝试加锁,这两个线程是同一个线程,如果允许这个操作,这个锁是可重入的,如果不允许这个操作(第二次加锁会阻塞等待),就是不可重入的,这个情况会导致死锁

,java把synchronized设定成可重入的了。

java标准库中的线程安全类

如果多个线程操作同一个集合类,就要考虑到线程安全的问题

Arraylist  、Linkedlist 、HashMap 、TreeMap、HashSet、TreeSet、StringBuilder  这些类在多线程代码中要格外注意

Vector、HashTable、ConcurrentHashMap、StrringBuffer 已经内置synchronized加锁,相对来说更安全一点

死锁

1.一个线程,一把锁,连续加锁两次,如果锁是不可重入锁,就会死锁

2.两个线程两把锁,t1和t2各自针对锁A和锁B加锁,再次尝试获取对方的锁

举个例子小明有一个羽毛球,它对这个羽毛球加锁了,小张有一双羽毛球拍,并对它加锁,小明说小张把你的羽毛球拍借给我用几天,而小张对小明说,你把你的羽毛球借我用几天,这不就僵住了吗?

   Thread thread1=new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (qiu)
                {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    synchronized (pai)
                    {
                        System.out.println("小明把球和球拍都拿到了");
                    }

                }

        }

Thread thread2=new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (pai)
                {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    synchronized (qiu)
                    {
                        System.out.println("小张把球和球拍都拿到了");
                    }
                }


            }
        });

我们执行这个程序的时候,会发现程序僵住了。

我们借助jconsole这样的工具来进行定位,看线程的状态和调用栈,分析代码在哪里死锁。

我们看到线程1此时是阻塞状态,而阻塞发生在17行

我们再来看一下线程2

此时线程2也是阻塞状态,阻塞发生在38行

这也就反映了当两个线程分对锁A、锁B加锁时,再尝试获取对方的锁时,会死锁。

3.多个线程多把锁

像这里有5个人桌子上是5双筷子,每个人都拿左边的筷子,并对其加锁,我们会发现那个人都吃不了饭,每个人都只有一双筷子。

死锁的必要条件:

1.互斥使用:

线程1拿到锁之后,线程2就得等着。

2.不可占用:

线程1拿到锁之后,必须是线程1主动释放,不能说线程2给强行获取到。

3.请求和保持:

线程1拿到锁A之后,再尝试获取锁B,A这把锁还是保持的(不会因为获取锁B,就把A给释放了)

4.循环等待:

线程1尝试获取到锁A和锁B 线程2尝试获取到锁B和锁A.线程1在获取B的时候等待线程2释放B,线程2在获取A的时候等待线程1释放A.

前三个条件是锁的基本特性,循环等待是这四个条件里唯一一个和代码结构相关的,也是我们可以控制的。

如何打破死锁呢?突破口就是循环等待

办法:给锁编号,然后指定一个固定的顺序(比如从小到大)来加锁,让线程遵守这个顺序,此时循环等待自然破除。

我们给筷子编了个号,然后让每个人都拿两边小的那一个, 然后我们发现e此时就可以拿到1和5这个筷子,那么它就可以吃饭了,当它吃完的时候,1和5放下,此时d也可以拿起5、4吃饭了,然后依次类推,每个人都能吃上饭,这样就破除了死锁。

明白了如何破除死锁我们再来看一下球和球拍那个代码,如果此时我们给这两个线程加锁固定个顺序,先加qiu,再加pai

public static void main(String[] args) {
        Object qiu=new Object();
        Object pai=new Object();
        Thread thread1=new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (qiu)
                {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    synchronized (pai)
                    {
                        System.out.println("小明把球和球拍都拿到了");
                    }

                }

        }


        });
        Thread thread2=new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (qiu)
                {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    synchronized (pai)
                    {
                        System.out.println("小张把球和球拍都拿到了");
                    }
                }


            }
        });
        thread1.start();
        thread2.start();


    }

我们看一下运行结果:

死锁的情况也就破除了。

内存可见性问题:

这个情况就是内存可见性问题,这也是一个线程不安全问题,一个线程读,一个线程改。

这里使用汇编来理解,大概就是这两操作,1.load ,把内存中flag的值,读取到寄存器里 2.cmp  把寄存器中的值,和0进行比较,根据比较结果,决定下一步往那个地方执行。由于load执行速度太慢(相当于cmp)来说,再加上反复load的结果都一样,编译器进行了优化,不再真正的重复load了,判定好像没有人改flag值,干脆只读取一次就好。

内存可见性问题:

一个线程针对一个变量进行读取操作,同时另一个线程对这个变量进行修改,此时读取到的值,不一定是修改之后的值,这个读线程没有感知到变量的变化,归根到底是编译器/jvm在多线程环境下优化时产生了误判。

此时我们给flag这个变量加上volatile关键字,意思就是告诉编译器,这个变量是"易变的",每次都要重新读取这个变量的内存内容,不要就行优化。

上述所说的内存可见性 编译器优化问题,也不是始终会出现的(编译器可能存在误判,但也不是100%就误判)

class MyCounter{
    public volatile int flag=0;
}


public class Mycount {

    public static void main(String[] args) {
        MyCounter myCounter=new MyCounter();

        Thread t1=new Thread(() ->{
            while(myCounter.flag==0)
            {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }


            }
            System.out.println("t1循环结束");

        });
        Thread t2=new Thread(() ->{
            Scanner scanner =new Scanner(System.in);
            System.out.println("请输入一个整数");
            myCounter.flag=scanner.nextInt();

        });
        t1.start();
        t2.start();
    }
}

像这段代码,如果我们在while循环里面加上个sleep,此时编译器就没有优化,while循环会终止,

从JMM角度重新表述内存可见性问题:  java程序里有主内存,每个线程还有自己的工作内存,t1线程进行读取的时候,只是读取了工作内存的值,t2线程进行修改的时候,先修改工作内存的值,然后把工作内存的内容同步到主内存中,但是由于编译器优化,导致t1没有重新从主内存同步数据到工作内存,读到的结果就是修改之前的结果,如果把主内存代替成咱们说的"内存"  把工作内存代替成”CPU"寄存器,工作内存,不只含有CPU的寄存器,还可能有CPU的缓存cache。

CPU读取寄存器,速度比读取内存快很多,因此会在CPU内部引入缓存cache  寄存器存储空间小,读写速度快,但是价格贵,中间搞了个cache,存储空间居中,读写速度居中,成本居中,内存存储空间大,读写速度慢,便宜。当CPU要读取一个内存数据时,可能直接读取内存,也可能是读cache,还可能是读取寄存器。

volatile不保证原子性,原子性是靠synchronized来保证的,synchronized和volatile都能保证线程安全,但是不能使用volatile处理两个线程并发++这样的问题。

wait 和notify

线程最大的问题就是抢占式执行,随机调度,于是我们发明了一些东西来控制线程之间的执行顺序,虽然线程在内核里的调度是随机的,但是可以通过一些cpi,让线程主动阻塞,主动放弃CPU,比如t1t2俩线程,希望t1先干活,干的差不多了,再让t2来干活,就可以让t2先wait(阻塞,主动放cpu),等t1干的差不多了,再通过notify通知t2,把t2唤醒,让t2接着干。

上述场景,使用join或者sleep行不行呢?

使用join,则必须让t1彻底执行完,t2才能运行,如果是希望t1先干%50的活,就让t2开始行动,join也无能为力,使用sleep,指定一个休眠时间,但是t1执行这些活,到底需要多少时间,不好估计。

wait进行阻塞,某个线程调用wait方法,就会进入阻塞,此时就处在WAITING,object.wait(),wait不加任何参数就是死等,一直等到其他线程唤醒它。wait加参数,指定了等待的最大时间

wait的带有等待时间的版本,看起来和sleep有点像,其实还是有本质区别的,虽然都能指定等待时间,虽然也能被提前唤醒(wait时使用notify唤醒,sleep是使用interrupt唤醒),但是notify唤醒wait

不会有异常,interrupt唤醒sleep则是出异常了。

wait,notify,notifyall  这几个方法,都是Object类的方法。

wait sleep区别总结:

1.相同点:都是使线程暂停一段时间

2.wait 是Object类的方法,而sleep是Thread类的方法

3.wait必须在synorchnoized修饰的代码块或方法里使用,而sleep在哪都可以

4.调用wait,线程进行BLOCK状态,调用wait线程会主动释放锁,而线程调用sleep会处于TIMED_WAIT状态,不涉及锁操作.

 public static void main(String[] args) {
        Object lock=new Object();
        Thread t1=new Thread(() -> {
            int i=0;
            for(i=0;i<5;i++)
            {
                ;

            }
            System.out.println("线程1已经执行完,去通知线程2执行");
            synchronized (lock) {
                lock.notify();
            }


        });
        Thread t2=new Thread(() ->{
            synchronized (lock) {
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("线程2已经执行完");

        });
        t2.start();
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        t1.start();
    }
}

像这段代码,我们在t2里面调用lock.wait()就是让t2线程等待t1让它先干完活,虽然这里阻塞了,阻塞在synchronized代码块里,实际上这里的阻塞是释放了锁的,此时其他线程是可以获取到object这个对象的锁的,此时这里的阻塞,就出在WAITING状态, 当t1干完活后,再调用lock.notify()通知线程2干活,线程2此时重新获取到锁。

我们要注意,启动线程的时候,先让t2先启动,过段时间再让t1先干活,因为如果先启动t1,可能会存在t1线程已经执行完了,而t2线程此时再执行,此时线程2就会一直阻塞下去,t1的notify已经执行完了,也就不起作用了。

      t1.start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        t2.start();

像这样线程2就会一直阻塞下去,没法被唤醒。

这里面还需要注意的一个点是wait(),notify()这两个方法需要搭配synchronized使用,为啥呢? 

因为wait操作,先释放锁,进行阻塞等待,收到通知以后,尝试获取锁,并且在获取锁之后,继续往下执行。 

notify和notifyAll区别

多个线程wait的时候,notify随机唤醒一个,notifyAll 所有线程都唤醒,这些线程在一起竞争锁。

三个线程分别只能打印ABC,且保证按照ABC的顺序打印。

public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(() -> {
            System.out.println("A");
            synchronized (locker1) {
                locker1.notify();//通知线程2,唤醒线程2
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (locker1) {
                try {
                    locker1.wait();//等待线程1
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

            System.out.println("B");

            synchronized (locker2) {
                locker2.notify();//通知线程3唤醒线程3
            }
        });
        Thread t3 = new Thread(() -> {
            synchronized (locker2) {
                try {
                    locker2.wait();//等待线程2
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("C");
        });

        t2.start();//先让t2 t3先启动,防止线程1的notify提前被调用,线程2就无法被唤醒
        t3.start();
        Thread.sleep(100);
        t1.start();
    }

单例模式:单个实例(对象)

在有些场景中,有的特定的类,只能创建一个实例类,不应该创建多个实例,java里实现单例模式有很多种,我们主要介绍两种:(1)饿汉模式 (2)懒汉模式

(1)饿汉模式

public class Signale {
//在此处,先把这个实例创建出来
    public static Signale signale=new Signale();
//如果需要使用这个唯一实例,统一通过Singale.getInstance()
    public static Signale getInstance()
    {
        return signale;
    }
//把构造方法设为Private,在类外面,就无法通过new的方式来创建Singale实例了。
    private Signale()
    {

    }
}

像这里static修饰这个对象,就会保证这个实例是唯一的,保证这个实例在一定时机被创建起来 。

我们用private这个属性,在类外面就无法通过new的方式来创建这个Singale实例了。

类对象本身和static没关系,而是类里面使用static修饰的成员会作为类属性,也就相当于这个属性对应的内存空间在类对象里面

class text123
{
    public int a;
    public static int b;


}


public class Thread5 {
    public static void main(String[] args) {
        text123 t1=new text123();
        text123 t2=new text123();
        //两个实例分别指向两份不同的a
        t1.a=20;
        t2.a=30;
        //被static 修饰的b只有一份,两次都指向同一个b
        text123.b=10;
        text123.b=20;
        System.out.println("t1="+t1.a);
        System.out.println("t2="+t2.a);
        System.out.println(text123.b);
    }

}

类加载:运行一个java程序,就需要让java进程能够找到并读取对应的.class文件就会读取文件内容,并解析并构成类对象,这一系列的过程操作,叫做类加载。

单例模式的懒汉模式实现:

public class Signallazy {
    public static Signallazy signallazy=new Signallazy();
    public Signallazy getInstance()
    {
        if(signallazy==null)
        {
            signallazy=new Signallazy();
        }
        return signallazy;
        
    }
    private Signallazy()
    {
        
    }
}

我们再把饿汉模式拿过来看一下:

public class Signale {
//在此处,先把这个实例创建出来
    public static Signale signale=new Signale();
//如果需要使用这个唯一实例,统一通过Singale.getInstance()
    public static Signale getInstance()
    {
        return signale;
    }
//把构造方法设为Private,在类外面,就无法通过new的方式来创建Singale实例了。
    private Signale()
    {

    }
}

这两个模式哪个是安全的呢?

像饿汉模式我们知道它只涉及读,不涉及修改,那么它应该是安全的。

而懒汉模式我们发现它涉及读和修改两种操作,如果不给它加锁,它是不安全的。

如果是一个线程,那么是安全的,但是如果是多个线程,像这里的t2线程就会读到“脏数据",也就是未修改后的值。

那么我们应该加上锁

public class Signallazy {
    public static Signallazy signallazy=new Signallazy();
    public Signallazy getInstance()
    {
        synchronized (Signallazy.class) {
            if (signallazy == null) {
                signallazy = new Signallazy();
            }

          }
       return signallazy;

    }
    private Signallazy()
    {

    }
}

那这样是不是就完美了呢?我们知道加锁操作是有开销的 ,当signallazy一旦不为空时,此是会直接返回signallazy,相当于一个是比较操作,一个是返回操作,这两个都是读操作,而不涉及修改操作,此时就不需要加锁了,因此我们在外边在判断一下signallazy是否为空,是否需要加锁就行

public class Signallazy {
    public static Signallazy signallazy = new Signallazy();

    public Signallazy getInstance() {
        if (signallazy == null) {  //第一个if用来判断是否需要加锁,
            synchronized (Signallazy.class) {
                if (signallazy == null) {//第二个if用来判断是否需要new对象
                    signallazy = new Signallazy();
                }

            }
        }
        return signallazy;
    }
    
    private Signallazy()
    {

    }
}

第一个if语句用来判断是否需要加锁,第二个if语句用来判断是否 需要new对象。

那么代码这样写是不是就安全了吗?我们明白只有第一次读才是读的内存,后面读的都是寄存器和cache,内存可见性问题,另外还会涉及到指令重排序问题。

指令重排序问题:本质上是编译器优化出了问题。

signallazy=new Signallazy()  拆分成三个步骤:1.申请内存空间  2.在内存空间里构造合法的对象

3.把内存空间的地址赋值给引用signallazy.

如果编译器为了提高效率,调整代码顺序,出现指令重排序的问题正常顺序是1、2、3,这时可能为1、3、2如果是单线程,2和3顺序颠倒不会出现问题,但是如果是多线程,假设线程1按照1、3、2执行,当执行完3,执行2之前,线程1被切除CPU,线程2执行,在线程2看起来此处引用非空,就直接返回了,但是由于t1还没执行2操作,此时t2拿到的是一个非法对象,还没构造完成的不完全对象。

针对内存可见性和指令重排序问题,我们需要用volatile!!!!

 public volatile static Signallazy signallazy = new Signallazy();

用volatile修饰一下signallazy就可以了。

下边这就是单例模式懒汉模式的安全版本了。

public class Signallazy {
    public volatile static Signallazy signallazy = new Signallazy();

    public Signallazy getInstance() {
        if (signallazy == null) {  //第一个if用来判断是否需要加锁,
            synchronized (Signallazy.class) {
                if (signallazy == null) {//第二个if用来判断是否需要new对象
                    signallazy = new Signallazy();
                }

            }
        }
        return signallazy;
    }

    private Signallazy()
    {

    }
}

猜你喜欢

转载自blog.csdn.net/m0_70386582/article/details/128170369
今日推荐