zoukankan      html  css  js  c++  java
  • 阅读《Effective Java》每条tips的理解和总结(6)

    76 保持失败原子性

        类似于数据库事务,一个失败的操作不应该产生任何影响。Java程序也是如此,一个执行过程成功,变量和对象则应该从一个正确的状态到另一个正确的状态;一个过程执行失败,则其中间产生的影响不应该生效,所以在抛出异常要注意保持失败原子性。一般而言,失败的方法调用应该使对象保持在被调用之前的状态。具有这种属性的方法被称为具有失败原子性。

    保持失败原子性一般有:(1)传入方法的对象是Immutable时,则方法不可能会影响此对象。(2)调整处理对象的顺序,将异常检查放在计算之前。例如栈的pop操作首先检查size是否为0,再返回element[--size],如果不先检查size为0直接抛出虽然也能正确抛出数组越界异常,但是影响了size,把size错误的置为-1。(3)对传入的对象进行拷贝,然后只操作拷贝后的对象。这个方法的适用范围比前两种广很多。

        总而言之,作为方法规范的一部分,它产生的任何异常都应该让对象保持在调用该方法之前的状态。如果违反这条规则,API文档就应该清楚地指明对象将会处于什么样的状态。

    77 不要忽略异常 

        很简单,就是不要用空的catch块来处理异常。面对异常,要么捕获并处理它,让程序继续执行;要么传播出去,让程序执行到此结束(都要打印出具体信息,以便调试)。不要捕获但不处理,忽略异常可以暂时让程序悄悄继续执行,但是由于不正确的状态很可能会在后面执行失败,可能会造成更严重的后果。

    78 同步访问共享的可变数据 

        很多人认为synchronized等同步机制就是保证代码块的互斥访问,其实这样理解不全面。(1)同步不仅可以让线程访问同步块时不被其他线程“打断插入”。(2)还可以让一个进入同步块的线程“看见”之前的线程把对同步块的修改,可以“看见”上一个线程执行完毕后共享变量的值/状态。这一点是很重要的,例如很多人忽视了第二点而错误的认为:操作原子类型的数据不需要同步,例如:

     public class Main{
         private static Boolean flag; 
         public static void main(String[] args) throws InterruptedException {
            Thread t = new Thread(() -> {
                while (!flag)  //标志部位true,一直循环
                     ....;  //doSomething
            );
            t.start();   //启动线程
            flag = true;  //将标志设为true
        }
    }     

    上面的代码没有同步的访问flag,乍一看没有问题,但线程 t 却可能永远不会终止。这是因为虚拟机发现flag没有同步访问,则认为flag值不受其他线程影响,从而将 t 线程代码优化成这样:

    if (!flag) {   //虚拟机认为优化成这样,先判断一次就行,不用每次循环都要判断。因为它认为flag不会受其他线程影响
        while (true)
            ...doSomething;
    } 

    (OpenJDK Server VM虚拟机的结果)。所以我们要在这个代码里对flag的访问改为同步方式(读和写都要同步),这个同步的目的不是为了互斥,而是为了这个共享变量的通信效果正常。(防止虚拟机指令重排)

    当然,保证原子类型的共享变量通信效果还可以使用volatile修饰。定论:如果变量是多线程共享的,且线程对其的某一个操作不具有原子性则一定要使用同步的方式读写保证原子性和可见性;如果线程对变量的所有操作本身是原子的,那么只需要给变量加上volatile就行。(int a = 1;是原子性变量,但只是只使用单次读写可以不同步。像a++这中操作不是单次读写,需要加同步。AotomicInteger的运算本身是原子的,仅仅是运算则不需要加同步)

    79 避免过度同步

        前一条说要同步的访问共享数据,但并不是说同步一定更好,相反我们应该尽量减少不必要的同步。这并不是因为加锁这个操作开销很大,而是加了锁后会使得cpu不能切换线程,失去了并行的机会。为了避免过度同步,应该注意:

    1.在一个被同步的区域内部,不要调用设计成要被覆盖的方法,或者是以函数对象的形式提供的方法。从包含该同步区域的类的⻆度来看,这样的方法是外来的。这个类不知道该方法会做什么事情,也无法控制它。根    据外来方法的作用,从同步区域中调用它会导致异常、死锁或者数据损坏。 

    2. 不光不要调用外来方法,通常来说,应该在同步区域内做尽可能少的工作。获得锁,检查共享数据,根据需要转换数据,然后释放锁。 如果你必须要执行某个很耗时的动作,则应该设法把这个动作移到同步区域的外面。因为很同步块内执行很耗时的动作,会使得cpu失去很多并行的机会。

        还有就是,设计一个类的时候要考虑清楚是实现外部同步还是内部同步。外部同步,比如java.util的集合类(除了已废弃的HashTable、Vector),他们的实现代码都没有同步,这样可以提高吞吐量,需要同步时就只能再外部即使用它的代码处进行同步;而内部同步比如java.util.concurrent包的类,里面的代码就实现了对属性的同步操作(内部同步可以使用分段锁、cas+volatile实现非阻塞同步提高并发),外部使用这些集合的代码就不要同步了。

        新建类时除非非常确定要内部同步,否则不要使用内部同步,外部同步灵活选择是否同步可以避免cpu失去很多并行机会。使用内部同步的时候应该清楚的在文档中说明。

    80 executor、task和stream优先于线程 

        如果想灵活的使用线程处理多个任务,以前一般是写一个工作队列,但是那样极容易出错。现在有现成的类库供使用,那就是线程池,线程池的具体这里不表,总之就是相比线程要更灵活、更优雅。

        简单总结下线程池即Executor框架为什么更好的原因,主要就是有这个执行框架的而存在,使得执行机制和工作单元分开了。直接使用线程Thread,Thread既是执行机制也充当工作单元。而让一个executorService替我们执行任务,在选择适当的执行策略方面就获得了极大的灵活性。

    81 并发工具优于wait和notify 

        wait()、notify/notifyAll是专门进行并发编程的方法,但是有了并发工具后,这些方法就像是并发编程的“汇编语言”,因为我们有更高级的办法代替。这些并发工具就是JUC包里的一系列东西:同步器、执行框架、并发集合等等。如果自己使用wait、notify的话有很多易犯错的地方,例如:

      虚假唤醒: notify/notifyAll时唤醒的线程并不一定是满足真正可以执行的条件了。比如对象o,不满足A条件时发出o.wait(),然后不满足条件B时也发出o.wait;然后条件B满足了,发出o.notify(),唤醒对象o的等待池里的对象,但是唤醒的线程有可能是因为条件A进入等待的线程,这时把他唤醒条件A还是不满足。这是底层系统决定的一个小遗憾。为了避免这种情况,判断调用o.wait()的条件时必须使用while,而不是if,这样在虚假唤醒后会继续判断是否满足A条件,不满足说明是虚假唤醒又会调用o.wait()。

          丢失唤醒:这种情况是wait、notify没有在同步块时会发生。线程A执行代码调用了o.wait()后,由于没有在同步块线程A还没有进入等待池,线程B就拿到了o的锁并发出了notify,由于线程A还没有进入等待唤醒信号就会被忽略,等线程A继续执行进入等待就没有notify来了。

    可以看到,坑还是挺多的。没有理由在新代码中使用wait方法和notify方法,即使有,也是极少的。如果你在维护使用wait方法和notify方法的代码,务必确保始终是利用标准的模式从while循 环内部调用wait方法。一般情况下,应该优先使用o.notifyAll()方法,他唤醒在o等待池的线程,更保守一些。如果使用 notify方法,请一定要小心,以确保程序的活性。 

    82 文档应包含线程安全属性 

        文档中应该明确记录类的线程安全信息,不能靠是否有synchronized关键字判断是否线程安全。文档记录的线程安全信息一般有一下加个级别:

      不可变:这种情况是绝对安全的,且是天然的,不是靠锁和关键字实现的,如String。

      无条件线程安全:说明这个类的所有代码段都已经内部同步好了,外部无需考虑线程安全问题。

      有条件线程安全:部分方法需要外部同步。这种情况应该不仅记录类的安全信息,还应该把方法的具体安全信息记录,包括是否安全、获取哪些锁等。

      线程对立:即使外部同步也不安全。一般是由于没有同步就去修改静态数据。这种类应该避免,即使有也应该在后续废弃。

    一般来说,编写无条件线程安全的类时,我们都使用锁对象如JUC的Lock(更灵活);注意1. 持有锁对象的同步方式更适合可能要被继承的类,因为如果使用synchronized使用实例锁子对象和父对象会互相干扰(子类对象即也是父类对象); 2. 持有锁对象应该是private、final的,如果锁对象暴露且可以更改,外部就可以恶意占有、修改锁,后果很严重。

    83 明智审慎的使用延迟初始化 

        延迟初始化并不总是一个好选择,虽然节约了初始化类的消耗以及在一段时间内节约了一些内存,但是代价是增加了访问字段的成本。因此,大多数情况常规初始化是优于延迟初始化的,只有某个字段的很大初始化代价高,访问的频率很低才考虑延迟初始化。

        延迟初始化往往要考虑线程安全问题,一般的做法就是双重检测+volatile修饰字段,如果字段是static时使用一个Holder Class是更好的选择。

    84 不要依赖线程调度器 

        这条tips的建议就是不要依赖和相信操作系统的线程调度器。虽然操作系统的线程调度器一般会公平的调度线程,但是并不是一个确定的结果。例如我们的程序启动了巨量的线程,然后把其中的重要、业务繁重的线程提高优先级或者使用yeild()使某些线程的总是被调度器选中执行,这都是不可取的,因为这必须依赖操作系统。我们应该自己掌握自己的线程,控制程序的线程数量合适,减少无用线程,且正确同步,这样操作系统就只能运行有用的线程。

        总之,虽然优先级、yeild在一些情况下调优会用到,但是程序不能依赖优先级、yeild等完成我们的预期目标,这些仅仅只能给操作系统一个提示并不保证可靠。

    85  优先选择Java序列化的替代方案 

      Java的序列化是一种十分危险的技术,尤其是反序列化时,将接收到的字节流转化成类然后任其执行是一件极其没有保证的事,因为无法缺点接收到的字节流是否是恶意的。因此,书中的建议就是不要使用Java的序列化和反序列化。但是,如果要构建一个分布式系统,设计几个服务的交互时序列化是不可避免的,一般的解决方式是折中采用其他的结构化数据,或者叫其他的序列化方式。比如Json、protocol,这些跨平台结构数据表示绝大多数情况可以满足分布式系统,而且跨平台前端后端都可以,后端里php、java都支持。这些结构数据表示与Java序列化的区别在于,Json只对类的基础属性等进行序列化,不是Java序列化那样完全的将一个类和字节流进行转化,这样安全性就得到了保障。

      Java9中添加的对象反序列化筛选,并可以其移植到早期版本(java.io.ObjectInputFilter)。该工具可以指定一个过滤器,该过滤器在反序列化数据流之前应用于数据流。它在类粒度上运行,允许你接受或拒绝某些类。默认接受所有类,并拒绝已知潜在危险类的列表称为黑名单;在默认情况下拒绝其他类,并接受假定安全的类的列表称为白名单。优先使用白名单。

      总之,不要使用序列化,可以用Json代替。如果特殊情况,或者老项目用到了,也应该再反序列化时使用过滤器只序列化某些类将风险降到最低。

    86 非常谨慎地实现Serializable 

      主要原因是一个类如果实现了Serializable接口,将使得这个类的灵活性下降。1 因为在一个类使用默认的序列化的方式发布后,如果你对它做了更改再试图用之前的旧版本序列化成新版本实例就会出错。2 由于要测试新版本和旧版本之间的兼容性,这样还会增加测试的成本。 3 如前一条所说,序列化会增加风险和漏洞。 

         注意:内部类不应该使用Serializable,因为它的默认序列化形式是不确定的。但是静态成员类是可以实现Serializable 。

    87 考虑使用自定义的序列化形式 

      注意:ObjectInputStream、ObjectOutputStream的readObject()、writeObject()等方法只是一个入口方法,决定一个对象的序列化具体流程和方式的还是对象本身的readObject()、writeObject()等方法,对象本身的readObject()等方法会在流的readObject()方法中通过反射的方式调用,这也是为什么对象的readObject()方法是private却能起作用的原因。序列化和反序列化有一个默认方法:

    os.writeObejct(object);//将对象以字节的形式写入输出流(ObjectOutputStream)
    Object o = is.readObject();  //将输入流的字节转化成对象(ObjectInputStream)

    但是这个方法默认会对一个类的整体起作用,它会描述实例的数据、实例持有的实例、实例之间互相关联的拓扑结构。所以,默认序列化方法其实是完整的描述了对象在物理层面的表示方法,如果我们需要的就是对象的物理表示相关信息,比如一个实体学生类只包含基础数据信息,默认序列化方法当然是合适的。但是有时我们只想序列化我们需要的逻辑数据那部分(这样还可以减少开销),那就需要自定义序列化方法。要注意:无论是否使用默认序列化方法,没有被transient修饰的属性都会被序列化。

    88 保护性的编写readObject方法

      就和防御性拷贝一样,当要保持一个类的不可变性,就必须在根据传入的对象参数给成员属性赋值前进行拷贝,然后将拷贝的对象赋给成员属性,总之就是不要让类的成员属性的引用可以从外部拿到从而被修改。反序列化时readObject方法也是一样,根据输入的字节流生成对象以及对象持有的引用时,对于对象持有的引用则重新new一个赋给它。例如:

    private void readObject(ObjectInputStreams) throwsIOException,ClassNotFoundException{     
        s.defaultReadObject();  //默认的序列化
        start = new Date(start.getTime());   //然后拷贝一份序列化的对象,将新引用赋给属性,因为旧引用在外部暴露了
        end = new Date(end.getTime()); 
    }

    89 对于实例控制,枚举类型优于readResolve 

      第3条提到,单例模式的最好实现方法是枚举,其他的方法都有被序列化攻击造成“不止一个实例的”可能。其实,在面对序列化攻击不满足单例的情况还是有一定手段应对的,比如提供一个readResolve()方法:

    class Singleton implements Serializable {
        public static final Singleton INSTANCE = new Singleton (); 
        transient private Student s = xxx; //要保证单例,引用类型的字段一定要用transient修饰
        private Object readResolve() {  // 提供这个方法,覆盖掉反序列化生成的对象,还是返回已有的单例实例。
            return INSTANCE; 
        }   
    }

    像上面的做法,生成反序列化的对象后,但是在readResolve()方法里忽略掉它,然后这个生成的新对象会被回收。但是,使用这种方法一定要将引用类型的属性用transient修饰,否则攻击方还是有办法在readResolve()方法执行之前将反序列化生成的Student对象赋给单例INSTANCE,这样就更改了原单例实例的引用指向新生成的Student对象。具体的做法是编写一个盗用者类,这里不表。

      最简单有效的办法还是单例。总而言之,应该尽可能的使用枚举类型来实施实例控制的约束条件。如果做不到,同时又需要一个即可序列 化又可以实例受控的类,就必须提供一个readResolve方法,并确保该类的所有实例化字段都被基本类型,或者是引用类型单被transient修饰。

    90 考虑用序列化代理代替序列化实例 

      与前面说的防御性拷贝、编写保护性的readObject()方法类似,使用序列化代理代替序列化实例显得更彻底:它不是每次为反序列化生成的对象的引用属性重新赋值,而是整个重新生成另一个类的实例,这“另一个类”的实例与反序列化生成对象的内容是相同的。

    private Object writeReplace() { 
        return new SerializationProxy(this);     //在真实对象被序列化之前,就使用代理序列化对象替换调真实对象。真实对象不会被序列化
    } 

      序列化代理模式有两个局限性。它不能与可以被客户端拓展的类兼容(详⻅19条)。它也不能与对象图中包 含循环的某些类兼容:如果你企图从一个对象的序列化代理的readResovle方法内部调用这个对象的方法,就会 得到一个ClassCastException异常,因为你还没有这个对象,只有它的序列化代理。 

    总而言之,当你发现必须在一个不能被客户端拓展的类上面编写readObject或者writeObject方法 时,就应该考虑使用序列化代理模式。想要稳健的将带有重要约束条件的对象序列化时,这种模式是最容易的方法。

  • 相关阅读:
    【转】将项目打成war包并用tomcat部署的方法,步骤及注意点
    JETTY+NGINX
    【转】收集 jetty、tomcat、jboss、weblogic 的比较
    SQL左右连接中的on and和on where的区别
    定义一个servlet用于处理所有外部接口类 架构思路
    spring上下文快速获取方法
    jasper打印实例2 ----通过文件字节流获得PDF格式图片
    Jasper打印示例
    Jasperreport5.6.9-----1
    Linux装B命令
  • 原文地址:https://www.cnblogs.com/shen-qian/p/12469728.html
Copyright © 2011-2022 走看看