• Java线程同步


    https://www.atatech.org/articles/64212

    Java线程同步方法与工具

    大多数程序中,为了提高程序的执行效率的,会创建多个线程进行执行。但是多个线程同时运行的时候可能调用线程函数,同时对同一个内存地址进行写入,由于CPU时间调度上的问题,写入数据会被多次的覆盖,所以就要使线程同步。

    线程同步:即当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作, 其他线程才能对该内存地址进行操作,而其他线程又处于等待状态。线程的同步让多个运行的线程良好地协作,让多线程按要求合理地占用释放资源。目前实现线程同步的方法有很多,本文讲的是在Java中一般怎么实现线程同步,以及一些很实用的线程同步工具。本文仅做一些抛砖引玉作用。。

    先看下本片文章大概内容的思维导图

    一、synchronized 实现同步

    synchronized 是Java语法关键词,在多线程并发编程中synchronized一直是元老级的角色,很多人都会称呼它为“重量级锁”,虽然Java 1.6对synchronized进行了优化,有些情况下并不会那么重量级。

    synchronized在java中的具体表现为以下3中形式

    1. 修饰普通同步方法,锁是当前实例对象
    2. 修饰静态同步方法,锁是当前类Class对象
    3. 修饰代码块,锁是synchronized括号的对象

    当一个线程试图访问同步代码时候,必须先取得以上对应对象锁,如果锁被其他线程占用时候,线程就会挂起等待,直到占用锁线程执行完毕或者异常退出释放占用的锁。

    1、修饰普通同步方法

    //实例方法同步
    public synchronized void get(){
        System.out.println(Thread.currentThread().getName()+"calling get....");
    }
    
    //实例方法同步
    public synchronized void set(){
        System.out.println(Thread.currentThread().getName()+"calling set....");
    }
    

    2、修饰静态同步方法

    //静态方法同步
    public synchronized static void add(){
        System.out.println(Thread.currentThread().getName()+"calling add....");
    }
    
    //静态方法同步
    public synchronized static void remove(){
        System.out.println(Thread.currentThread().getName()+"calling remove....");
    }
    

    3、修饰代码块

    private static final Object sLock = new Object();
    private void blockSync(){
        new Thread(new Runnable() {
    
            @Override
            public void run() {
                synchronized (sLock) {
                    //do something
                }
            }
        }).start();
    }
    

    4、synchronized 等待唤醒机制

    调用Object类wait()方法,可以将当前线程挂起加入 Lock对象的等待线程队列,调用Object类notify()、notifyAll()方法可以唤醒线程

    需要注意的是:

    1. 调用Object类的wait(), notify()方法前,必须获得obj锁,也就是必须写在synchronized(obj) {…} 代码段内。
    2. 调用obj.wait()后,线程A就释放了obj的锁。
    3. 当obj.wait()方法返回后,线程A需要再次获得obj锁,才能继续执行。
    4. 如果A1、A2、A3都在wait(),则B调用notify()只能唤醒A1,A2,A3中的一个(具体哪一个由JVM决定)。
    5. notifyAll()则能全部唤醒A1、A2、A3,但是要继续执行wait()的下一条语句,必须获得obj锁,因此,A1,A2,A3只有一个有机会获得锁继续执行,例如A1,其余的需要等待A1释放obj锁之后才能继续执行。
    6. 当B调用notify/notifyAll的时候,B正持有obj锁,因此,A1,A2,A3虽被唤醒,但是仍无法获得obj锁。直到B退出synchronized块,释放obj锁后,A1、A2、A3中的一个才有机会获得锁继续执行。

    以下是简单例子

    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (sLock) {
                    try {
                        if(condition){
                            //挂起到sLock等待线程队列
                            sLock.wait();
                        }
    
                        add();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();
    
        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (sLock) {
                    //do something
                    sLock.notify();
                    sleep(3000);
                }
            }
        }).start();
    }
    

    二、Lock类实现同步

    锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁能防止多个线程同时访问共享资源。在Lock接口出现前,Java程序是靠synchronized关键字实现锁的功能,Java 1.5之后,新增了Lock接口来实现锁的功能,它提供了与synchronized类似的同步功能,只是在使用的时候需要手动的获取和释放锁,虽然缺少了synchronized隐式释放锁的便捷性,但是却拥有了锁获取与释放的可操作性、可中断的获取锁以及超时获取锁等多种synchronized不具备的同步特性。

    Lock使用也很简单,以下是简单的使用方式。

        final Lock lock =new ReentrantLock();
        new Thread(new Runnable() {
    
            @Override
            public void run() {
                lock.lock();
                //do something
                try{
    
                }finally{
                    //finally模块释放锁,目的是保证获取的锁最终能被释放
                    lock.unlock();
                }
            }
        });
    

    1、ReentrantLock 可重入锁

    上面使用的例子即ReentrantLock可重入锁,指的是同一个线程多次试图获取它所占有的锁,请求会成功。当释放锁的时候,直到重入次数清零,锁才释放完毕。

    ReentrantLock 由最近成功获得锁,并且还没有释放该锁的线程所拥有。当锁没有被另一个线程所拥有时,调用 lock 的线程将成功获取该锁并返回。如果当前线程已经拥有该锁,此方法将立即返回。可以使用 isHeldByCurrentThread() 和 getHoldCount() 方法来检查此情况是否发生。

    Lock接口提供的synchronized不具备的同步特性

    1. tryLock() 尝试非阻塞的获取锁,如果获取到则持有锁,否则返回
    2. tryLock(long time, TimeUnit unit) 超时获取锁,截止时间没获取锁即返回
    3. lockInterruptibly() 该方法可以响应中断,即在锁获取过程中可以中断当前线程。
    4. ReentrantLock必须在finally中释放锁,否则后果很严重,编码角度来说使用synchronized更加简单,不容易遗漏或者出错。
    5. ReentrantLock 的性能比synchronized会好点。
    6. synchronized锁的范围是整个方法或synchronized块部分;而Lock因为是方法调用,可以跨方法,灵活性更大
    7. ReentrantLock可以实现fair lock,就是看获得锁的顺序是不是和申请锁的时间的顺序是一致的

    2、ReentrantReadWriteLock读写锁

    读写锁分为读锁和写锁,多个读锁不互斥,读锁与写锁互斥。

    线程进入读锁的前提条件:

    1. 没有其他线程的写锁,
    2. 没有写请求或者有写请求,但调用线程和持有锁的线程是同一个

    线程进入写锁的前提条件:

    1. 没有其他线程的读锁
    2. 没有其他线程的写锁

    以下是简单的使用实例

    public void readWriteLockTest(){
        final ReentrantReadWriteLock lock =new ReentrantReadWriteLock();
        Thread t1=new Thread(new Runnable() {
    
            @Override
            public void run() {
                lock.readLock().lock();
                //do sth here
                lock.readLock().unlock();
            }
        });
    
        Thread t2=new Thread(new Runnable() {
    
            @Override
            public void run() {
                lock.writeLock().lock();
                //do sth here
                lock.writeLock().unlock();              
            }
        });
    }
    

    需要注意的是:

    1. 重入方面其内部的WriteLock可以获取ReadLock,但是反过来ReadLock不能获得WriteLock。
    2. WriteLock可以降级为ReadLock,方法是,先获得WriteLock再获得ReadLock,然后释放WriteLock,这时候线程将保持Readlock的持有。
    3. ReadLock可以被多个线程持有并且在作用时排斥任何的WriteLock,而WriteLock则是完全的互斥。这一特性最为重要,因为对于高读取频率而相对较低写入的数据结构,使用此类锁同步机制则可以提高并发量。
    4. 不管是ReadLock还是WriteLock都支持Interrupt,语义与ReentrantLock一致。
    5. WriteLock支持Condition并且与ReentrantLock语义一致,而ReadLock则不能使用Condition,否则抛出UnsupportedOperationException异常。

    3、Lock同步等待和唤醒机制

    Lock使用Condition接口来替代传统的Object wait(), notify/notifyAll等待唤醒,你可以在Condition上调用await()来挂起一个任务。当外部条件发生变化,你可以通过调用signal()来通知这个任务,或者调用signalAll()来唤醒所有在这个Condition上被其自身挂起的任务。相比使用Object的wait()、notify(),使用Condition1的await()、signal()这种方式实现线程间协作更加安全和高效。同一个Lock可以neW多个Condition实例。

    private static ReentrantLock sLock = new ReentrantLock();
    private static Condition condition = sLock.newCondition();
    private static Condition condition2 = sLock.newCondition();
    
    private static void action() {
        new Thread("小明") {
            public void run() {
                sLock.lock();
                try {
                    condition.await();
                    a = "摘苹果";
                    System.out.println(Thread.currentThread().getName()
                            + "完成任务:" + a);
                } catch (InterruptedException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                } finally {
                    condition2.signalAll();
                    sLock.unlock();
                }
            };
        }.start();
    
        new Thread("小明") {
            public void run() {
                sLock.lock();
                try {
                    b = "大扫除";
                    System.out.println(Thread.currentThread().getName()
                            + "完成任务:" + b);
                } finally {
                    condition.signalAll();
                    sLock.unlock();
                }
            };
        }.start();
    
        sLock.lock();
        try{
            condition2.await();
            System.out.println("所有任务完成  a:" + a + " b:" + b);
        } catch (InterruptedException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }finally{
            sLock.unlock();
        }
    }
    

    三、volatile关键字

    在多线程并发编程中synchronized和Volatile都扮演着重要的角色,volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。线程为了提高效率,将某成员变量(如A)拷贝了一份(如B)到缓存,线程中对A的访问其实访问的是B。只在某些动作时才进行A和B的同步。因此存在A和B不一致的情况。volatile就是用来避免这种情况的。volatile告诉JVM, 它所修饰的变量不保留拷贝,直接访问主内存中的(也就是上面说的A)

    Java语言规范第三版中对volatile的定义如下: java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。volatile变量修饰符如果使用恰当的话,它比synchronized的使用和执行成本会更低,因为它不会引起线程上下文的切换和调度。

    private volatile boolean condition = false;
    
    public void setCondition(boolean c){
        condition = c;
    }
    
    private void volatileFunc(){
        while(!condition){
            //do something
        }
    }
    

    volatile一般情况下不能代替sychronized,因为volatile不能保证操作的原子性,即使只是i++,实际上也是由多个原子操作组成:read i; inc; write i,假如多个线程同时执行i++,volatile只能保证他们操作的i是同一块内存,但依然可能出现写入脏数据的情况。如果配合Java 5增加的atomic原子类,对它们的increase之类的操作就不需要sychronized。

    四、Atomic原子类非阻塞同步方式

    在大多数情况下,我们为了实现线程安全都会使用Synchronized或lock来加锁进行线程的互斥同步,但互斥同步的最主要的问题就是进行线程的阻塞和唤醒所带来的性能问题,因此这种阻塞也称作阻塞同步。随着硬件指令集的发展,我们有了另一个选择:基于冲突检测的乐观并发策略,通俗的说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功;如果共享数据有争用,产生了冲突,那就再采用其他的补偿措施(最常见的就是不断的重试,直到成功),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步措施称为非阻塞同步。

    原子(atom)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为"不可被中断的一个或一系列操作"。Java从JDK1.5开始提供了java.util.concurrent.atomic包,方便程序员在多线程环境下,无锁的进行原子操作。原子变量的底层使用了处理器提供的原子指令,但是不同的CPU架构可能提供的原子指令不一样,也有可能需要某种形式的内部锁,所以该方法不能绝对保证线程不被阻塞。

    原子类内部实现是通过CAS(Compare and Swap)实现,现代的CPU提供了特殊的指令,可以自动更新共享数据,而且能够检测到其他线程的干扰,而 compareAndSet() 就用这些指令代替了锁定。比如以下AtomicInteger addAndGet()原子操作,内部循环使用compareAndSet

    /**
     * Atomically adds the given value to the current value.
     *
     * @param delta the value to add
     * @return the updated value
     */
    public final int addAndGet(int delta) {
        for (;;) {
            int current = get();
            int next = current + delta;
            if (compareAndSet(current, next))
                return next;
        }
    }
    
    /**
     * Atomically sets the value to the given updated value
     * if the current value {@code ==} the expected value.
     *
     * @param expect the expected value
     * @param update the new value
     * @return true if successful. False return indicates that
     * the actual value was not equal to the expected value.
     */
    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }
    

    整体的过程就是这样,利用CPU的CAS指令,同时借助JNI来完成Java的非阻塞算法, 其它原子操作都是利用类似的特性完成的。

    以下是Atomic类简单的使用Demo

    public class AtomicDemo {
        private static AtomicInteger mAI= new AtomicInteger(2);
        private static AtomicBoolean mAB = new AtomicBoolean(true);
    
        private static AtomicIntegerArray mAIA = new AtomicIntegerArray(5);
        private static AtomicReferenceArray<String> mARA =new AtomicReferenceArray<>(5);
    
        private static AtomicReference<People> mAR =new AtomicReference<>(new People("lili", 15));
        private static AtomicIntegerFieldUpdater<People> mAIFU = AtomicIntegerFieldUpdater.newUpdater(People.class, "age");
    
    
        private static AtomicStampedReference<People> mASR = new AtomicStampedReference<AtomicDemo.People>(new People("lili", 15) , 1);
        private static AtomicMarkableReference<People> mAMR = new AtomicMarkableReference<AtomicDemo.People>(new People("lili",15),true);
        /**
         * @param args
         */
        public static void main(String[] args) {
            atomicIntegerTest();
            atomicBooleanTest();
    
            atomicIntegerArrayTest();
            atomicReferenceArrayTest();
    
            atomicReferenceTest();
            atomicStampedReferenceTest();
            atomicMarkableReferenceTest();
    
            atomicIntegerFieldUpdaterTest();
    
        }
    
    
        private static void atomicIntegerTest(){
            System.out.println("=========atomicIntegerTest========");
            System.out.println(mAI.get());
            System.out.println(mAI.compareAndSet(2, 10));
            System.out.println(mAI.addAndGet(20));
        }
    
        private static void atomicBooleanTest(){
            System.out.println("=========atomicBooleanTest========");
            System.out.println(mAB.get());
            System.out.println(mAB.compareAndSet(false, true));
            mAB.getAndSet(true);
            System.out.println(mAB.get());
        }
    
        private static void atomicIntegerArrayTest(){
            System.out.println("=========atomicIntegerArrayTest========");
            System.out.println(mAIA.addAndGet(1, 100));
            System.out.println(mAIA.compareAndSet(2, 0,20));
            mAIA.getAndSet(1, 100);
            System.out.println(mAIA);
        }
    
        private static void atomicReferenceArrayTest(){
            System.out.println("=========atomicReferenceArrayTest========");
            System.out.println(mARA.getAndSet(0, "Hello"));
            System.out.println(mARA.compareAndSet(1, null,"Hehe"));
            mARA.getAndSet(3,"WTF");
            System.out.println(mARA);
        }
    
        private static void atomicReferenceTest(){
            System.out.println("=========atomicReferenceTest========");
            People p1 = new People("xiaoming", 18);
            System.out.println("Before set: "+mAR.getAndSet(p1));
            System.out.println("After set: "+mAR.get());
        }
    
        private static void atomicIntegerFieldUpdaterTest(){
            System.out.println("=========atomicIntegerFieldUpdaterTest========");
            People p1 = new People("xiaoming", 18);
            System.out.println("Before set: "+mAIFU.getAndSet(p1,10));
            System.out.println("After set: "+mAIFU.get(p1));
        }
    
        private static void atomicStampedReferenceTest(){
            System.out.println("=========atomicStampedReferenceTest========");
            People p1 = new People("xiaoming", 18);
            System.out.println("Before set: "+mASR.getStamp());
            mASR.compareAndSet(null, p1, 1, 10);
            System.out.println("After set: "+mASR.getStamp());
        }
    
        private static void atomicMarkableReferenceTest(){
            System.out.println("=========atomicMarkableReferenceTest========");
            People p1 = new People("xiaoming", 18);
            System.out.println("Before set: "+mAMR.getReference() +", marked = "+mAMR.isMarked());
            mAMR.attemptMark(p1, true);
            System.out.println("After set: "+mAMR.getReference()+", marked = "+mAMR.isMarked());
        }
    
    
        static class People{
            volatile int age;
            String name;
    
            public People(String name, int age){
                this.name = name;
                this.age =age;
            }
    
            @Override
            public String toString() {
                return name+"_"+age;
            }
        }
    
    }
    

    五、Java同步实用工具

    1、CountDownLatch

    CountDownLatch是一个计数器,这个计数器的操作是原子操作,同时只能有一个线程去操作这个计数器。你可以向CountDownLatch对象设置一个初始的数字作为计数值,任何调用这个对象上的await()方法都会阻塞,直到这个计数器的计数值被其他的线程减为0为止。CountDownLatch的一个非常典型的应用场景是:有一个任务想要往下执行,但必须要等到其他的任务执行完毕后才可以继续往下执行。

    如以下实例,小明摘完苹果,计数器减1,小花大扫除完,计数器减1。两个动作完成后,计数器清零,await()方法取消阻塞,返回。

    public static void main(String[] args) {
    
        new Thread("小明"){
            public void run() {
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
                a="摘苹果";
                System.out.println(Thread.currentThread().getName()+"完成任务:"+a);
                System.out.println("before A count:"+mCount.getCount());
                mCount.countDown();
                System.out.println("after A count:"+mCount.getCount());
            };
        }.start();
    
        new Thread("小花"){
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
                b="大扫除";
                System.out.println(Thread.currentThread().getName()+"完成任务:"+b);
                System.out.println("before B count:"+mCount.getCount());
                mCount.countDown();
                System.out.println("after B count:"+mCount.getCount());
            };
        }.start();
    
        try {
            mCount.await();
        } catch (InterruptedException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
        System.out.println("所有任务完成  a:"+a+" b:"+b);
    
    }
    

    注意:
    CountDownLatch不能重新初始化,或者修改对象内部计数器的值。

    2、CyclicBarriar 同步屏障

    CyclicBarriar字面意思是可循环使用的屏障,它功能是,当一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障,屏障才会开门,所有被屏障拦截的线程才会继续执行

    使用实例如下

    private volatile static String a=null;
    private volatile static String b=null;
    /**
     * @param args
     */
    public static void main(String[] args) {
    
        final CyclicBarrier c=new CyclicBarrier(2, new Runnable() {
    
            @Override
            public void run() {
                System.out.println("所有任务完成  a:"+a+" b:"+b);
            }
        });
    
        Thread t1=new Thread("小明"){
            public void run() {
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
                a="摘苹果";
                System.out.println(Thread.currentThread().getName()+"完成任务:"+a);
                try {
                    c.await();
                } catch (InterruptedException | BrokenBarrierException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
            };
        };
    
        Thread t2=new Thread("小花"){
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
                b="大扫除";
                System.out.println(Thread.currentThread().getName()+"完成任务:"+b);
                try {
                    c.await();
                } catch (InterruptedException | BrokenBarrierException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
            };
        };
    
        t1.start();
        t2.start();
    
    }
    

    CyclicBarriar可以reset重新初始化,比CountDownLatch灵活,所以在一些应用比较复杂的场景,优先使用CyclicBarriar

    3、Semaphore 信号量

    Semaphore当前在多线程环境下被广泛使用,在进程控制方面都有应用。Java 并发库 的Semaphore 可以很轻松完成信号量控制,Semaphore可以控制某个资源可被同时访问的个数,通过 acquire() 获取一个许可,如果没有就等待,而 release() 释放一个许可。如果初始化个数设置为1,Semaphore可以当作一个互斥锁进行使用。

    /**
     * 控制线程并发数,控制流量的同步工具
     * @author fangquan
     *
     */
    public class SemaphoreDemo {
        public static Semaphore sem = new Semaphore(2);
        /**
         * @param args
         */
        public static void main(String[] args) {
            Thread t1 = new MThread("t1");
    
            Thread t2 = new MThread("t2");
    
            Thread t3 = new MThread("t3");
    
            t1.start();
            t2.start();
            t3.start();
        }
    
        static class MThread extends Thread{
            public MThread(String name){
                super(name);
            }
            @Override
            public void run() {
                try {
                    sem.acquire();
                } catch (InterruptedException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName()+" 运行中。。。。");
                System.out.println(Thread.currentThread().getName()+" 运行结束");
                sem.release();
            }
        }
    }
    

    4、Exchanger

    Exchanger是Java 并发 API 提供的一种允许2个并发任务间相互交换数据的同步机制。更具体的说,Exchanger类允许在2个线程间定义同步点,在到达exchange交换点后线程会阻塞,当2个线程都到达这个点,exchange方法才返回,并且他们相互交换的数据,即第一个线程收到第二个线程传过来的数据,然后第二个线程收到第一个线程传过来的数据。

    使用实例

    public class ExchangerDemo {
    
    private static Exchanger<String> exchanger=new Exchanger<>();
        /**
         * @param args
         */
        public static void main(String[] args) {
            new Thread("小明"){
                public void run() {
                    try {
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        // TODO Auto-generated catch block
                        e.printStackTrace();
                    }
    
                    String a=null;
                    try {
                        a = exchanger.exchange("你好,我是小小明");
                    } catch (InterruptedException e) {
                        // TODO Auto-generated catch block
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName()+"听见声音: "+a);
                };
            }.start();
    
            new Thread("小花"){
                public void run() {
                    String b=null;
                    try {
                        Thread.sleep(1000);
                        //把null传给对方
                        b=exchanger.exchange(null);
                    } catch (InterruptedException e) {
                        // TODO Auto-generated catch block
                        e.printStackTrace();
                    }
    
                    String a=null;
                    System.out.println(Thread.currentThread().getName()+"听见声音: "+b);
                };
            }.start();
    
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
        }
    }
    

    以上例子,小明线程给小花传递了名字,而小花比较害羞,传了个null。结果就是小明收到null,小花收到“你好,我是小小明信息”。

    最后

    再次说明,本文仅做一个抛砖引玉的作用,在Java多线程同步应用领域,本人也在不断学习探索当中。

  • 相关阅读:
    [ Talk is Cheap Show me the CODE ] : jQuery Mobile工具栏
    Html 表格
    概率论机器学习的先验知识(上)
    ODPS 下一个map / reduce 准备
    Zygote过程【3】——SystemServer诞生
    Java获取的一天、本星期、这个月、本季度、一年等 开始和结束时间
    中国国家干部级别、职称级别、事业单位级别
    Multivariate Adaptive Regression Splines (MARSplines)
    在Visual Studio中开发Matlab mex文件,生成mexw64/mexw32
    Windows删除文件时出现,“正在准备 再循环”
  • 原文地址:https://www.cnblogs.com/diegodu/p/6812816.html
Copyright © 2020-2023  润新知