位运算在Java编程中的应用

计算机的世界,是0-1的世界。

位运算,是对比特位进行运算,执行上会更快?


一、原码-反码-补码


计算机中的有符号数有三种表示方法,即原码、反码和补码。三种表示方法均由符号位和数值位两部分组成,符号位用0表示“正”,用1表示“负”。

计算机系统中,数值都是以补码的形式表示和存储的,正数的补码就是其二进制表示,负数的补码是其绝对值数的原码取反后加1.

原码:数字的二进制表示,如10的二进制表示:00000000 00000000 00000000 00001010

反码:原码的每一个比特位取反,如10的反码为:11111111 11111111 11111111 11110101

补码:反码加1,-10的补码:11111111 11111111 11111111 11110110

补码存在的意义:解决了符号的表示问题并使得符号位能参与运算;将减法用加法的形式来表示。


二、常用的位运算

 

程序中的所有数在计算机内存中都是以二进制的形式储存的。位运算就是直接对整数在内存中的二进制位进行操作,常用的位运算有以下7种:


  • 与(&):相同位上,都为1,结果才是1

    5 & 3 = 1 (101 & 011 = 001)
    登录后复制
    • 或(|):相同位上,只要有一个1,结果则为1

      5 | 3 = 7 ( 101 | 011 = 111)
      登录后复制
      • 异或(^) :相同位上,不同则为1,相同则为0

        5 ^ 3 = 0 (101 ^ 011 = 110)
        登录后复制
        • 取反(~) :1变0,0变1

          ~5 = - 6
          登录后复制
          • 左移(<<) :低位补0

            正数,在不溢出的情况下,每左移一位相当于数值乘以2,7 << 1 = 14
            登录后复制
            • 右移(>>) :正数高位补0,负数高位补1

              在不溢出的情况下,每右移一位相当于除以2并向下取整,7 >> 1 = 3
              登录后复制
              • 无符号右移(>>>) :高位补0


              问题思考:为什么没有无符号左移?

              符号位本来就在左边(高位),假设每次无脑补0,则移完位后还得将最高位置为0,这就相当于两步操作了。

              三、位运算的应用

              借助于位运算,可以巧妙的解决某些问题,使实现更加优雅;节省存储空间,提供运行效率。

              3.1 开关类场景

              0与1天生用来表示对立的两种状态(开关)。若用一个int字段来表示一个状态开关,则当有多个开关时,需要多个int字段来表示和存储。这无疑增大了存储空间。
              一个int类型有32个位,理论上可以表示32个开关。
              带来的问题:多个开关存储成一个字段,在表示上,不能一眼看出具体某个开关的状态,需要经过运算才能得多结果,做不到所见即所得。
              解决这个问题的唯一途径就是:编写一些辅助方法来获取对应开关的实际状态。

              3.2 签到场景

              签到场景其实是开关类场景的具体应用,用一个int字段来表示用户一个月的签到情况,0表示未签到,1表示签到。
              想知道某一天是否签到,则只需要判断对应的比特位上是否为1.
              想知道一个月累计签到了多少次,只需要统计有多少个比特位为1,可以使用下面方法获得:
                Integer.bitCount()
                登录后复制
                3.3 动态调整接口的返回

                是开关类场景的另一种具体应用,场景描述如下:
                有个用户模块,需要对外提供获取用户信息的接口,用户信息包括:基本信息、会员信息、实名认证信息、粉丝信息、关注信息、勋章等。
                针对不同的展示位置,需要展示不同的信息,如果在一个接口中,将全部信息都返回,无疑是一种浪费,也会导致接口时延很高;
                如果针对每个具体场景,单独适配接口,这种情况下,当场景比较多时,需要实现多个,并且当后续用户信息扩展时,也需要针对扩展的信息单独添加新接口或者修改以前旧的接口,改动量会比较大。
                我们可以为每个用户信息块设置一个开关,当接口中出现该开关时,则表示需要获取对应部分的信息,具体实现如下:
                  public interface UserOptType{
                     long BASE_INFO = 1;
                     long VIP_INFO = 1 << 1;
                     long FANS_INFO = 1 << 2;
                     long ATTENTION_INFO = 1 << 3;
                     long MODEL_INFO = 1 << 4;
                     long REAL_NAME_INFO = 1 << 5;
                  }


                  public class UserService{
                      public Object getUserInfo(String userId,long optType){
                       Object result = new Object();
                          if ( optType & UserOptType.BASE_INFO > 0 ){
                              setBaseInfo(userId,result);
                          }
                          if ( optType & UserOptType.VIP_INFO > 0 ){
                              setVipInfo(userId,result);
                  }
                  // ......
                      }
                  }
                  登录后复制
                  3.4 判断数字的奇偶性

                  能被2整除的,就是偶数,否则就是奇数。根据定义,我们有如下的判断方式:
                    public boolean isEven(int input){
                    return input % 2 == 0;
                    }
                    登录后复制

                    从比特位上看,偶数最后一个比特位是0,奇数为1,因此也可以使用下面代码来判断:
                      public boolean isEven(int input){
                      return input & 1 == 0;
                      }
                      登录后复制

                      位运算实现的方式,有何优势?是否执行更快,这个还有待验证;理论上说,应该是会快,但对于现代的硬件来说,这点快可能很难感受到。但有一个点应该是可以肯定的,可以“装逼”。用位运算的方式实现看上去没有根据定义实现的代码来得直接。

                      3.5 一个字段表示多种含义


                      用一个字段来表示多个含义,可以减少存储;这一点JVM中的线程池ThreadPoolExcutor就有所体现。
                      使用AtomicInteger字段ctl,存储当前线程池状态和线程数量。高3位表示当前线程的状态,低29位表示线程的数量。
                      线程数量的增减直接对ctl字段进行增减。
                        private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
                        // 低29位表示线程数,因此最大线程数为 2^29 -1,大约500多万
                        // 实际应用中,计算机是不可能创建这么多线程的
                        // 以JVM为例,一个线程占用256K (-Xss),8G内存,最多也就创建 4 * 8 * 1024
                        private static final int COUNT_BITS = Integer.SIZE - 3;
                        private static final int CAPACITY = (1 << COUNT_BITS) - 1;


                        // 当前线程池的运行状态,存储在高位
                        // 用负数来表示运行中    
                        private static final int RUNNING    = -1 << COUNT_BITS;
                        private static final int SHUTDOWN = 0 << COUNT_BITS;
                        private static final int STOP = 1 << COUNT_BITS;
                        private static final int TIDYING = 2 << COUNT_BITS;
                        private static final int TERMINATED = 3 << COUNT_BITS;


                        // 对控制字段的解包和打包方法
                        // 求解出当前的运行状态,当前的线程数
                        private static int runStateOf(int c) { return c & ~CAPACITY; }
                        private static int workerCountOf(int c) { return c & CAPACITY; }
                        private static int ctlOf(int rs, int wc) { return rs | wc; }


                        // 直接使用CAS增加线程数
                        private boolean compareAndIncrementWorkerCount(int expect) {
                        return ctl.compareAndSet(expect, expect + 1);
                        }
                        private boolean compareAndDecrementWorkerCount(int expect) {
                            return ctl.compareAndSet(expect, expect - 1);
                        }
                        登录后复制

                        3.6 巧用 & 进行取余

                        当前的时代是大数据和高并发的时代,在数据库层面,通常会通过数据分表来提高性能,分表的常用方法就是对某个唯一性字段进行表数量的取余,以决定该数据属于那张表。
                        在位运算中,对一个2^n取余,其实就是与上(2^n - 1)。
                        这一点,在HashMap中有所体现,HashMap中,要求内部数组table[]大小是2^n,这样在计算key对应的下标时,就可以使用到与运算&。

                        3.7 使用位运算,巧妙实现算法

                        在HashMap的实现中,为了确保数组大小是2^n,会通过 tableSizeFor()方法来找出:第一个大于等于给定数值的2^n
                        从数学等比数列:1 2 4 8 ... 前 n-1个数的和就是2^n - 1
                        因此上面的问题可以转化成:将给定数值,第一个出现比特位为1,之后的所有低位都变为1.
                        因此,算法的实现如下:
                        第0步:先将原数值减1,目的就是为了满足(2^n - 1) = (n-1)位1所表示的数值,防止当给定的数值就是2^n,经过下面的操作会变成:2^(n+1),则会不满足条件。
                        第一步:n | (n >>> 1) 会将后1个比特位置为1,然后将该值赋值给n
                        第二步:n | (n >>> 2) 会将后3个比特位置为1,再将该结果复制给n
                        第三步:n | (n >>> 4) 会将后7个比特位置为1,再将该结果复制给n
                        第四步:n | (n >>> 8) 会将后15个比特位置为1,再将该结果复制给n
                        第五步:n | (n >>> 16)会将后31个比特位置为1,再将该结果复制给n

                         

                        当上面的某一步,低位已经全部是1之后,则后面的操作,不会改变结果。
                        当负数的时候,会在某一步上,比特位全为0.
                        • 0
                          点赞
                        • 0
                          收藏
                          觉得还不错? 一键收藏
                        • 0
                          评论

                        “相关推荐”对你有帮助么?

                        • 非常没帮助
                        • 没帮助
                        • 一般
                        • 有帮助
                        • 非常有帮助
                        提交
                        评论
                        添加红包

                        请填写红包祝福语或标题

                        红包个数最小为10个

                        红包金额最低5元

                        当前余额3.43前往充值 >
                        需支付:10.00
                        成就一亿技术人!
                        领取后你会自动成为博主和红包主的粉丝 规则
                        hope_wisdom
                        发出的红包
                        实付
                        使用余额支付
                        点击重新获取
                        扫码支付
                        钱包余额 0

                        抵扣说明:

                        1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
                        2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

                        余额充值